synlynk 0.27.5__py3-none-any.whl

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 (284) hide show
  1. synlynk/__init__.py +3672 -0
  2. synlynk/__main__.py +4 -0
  3. synlynk/_constants.py +374 -0
  4. synlynk/_fast_cli.py +56 -0
  5. synlynk/_lazy.py +15 -0
  6. synlynk/addon.py +26 -0
  7. synlynk/advisory.py +131 -0
  8. synlynk/agent_cli.py +365 -0
  9. synlynk/agent_store.py +421 -0
  10. synlynk/approval_gate.py +41 -0
  11. synlynk/attribution.py +232 -0
  12. synlynk/autonomy.py +71 -0
  13. synlynk/backlog.py +1007 -0
  14. synlynk/backlog_extractor.py +126 -0
  15. synlynk/backup.py +325 -0
  16. synlynk/baseline.py +179 -0
  17. synlynk/board.py +344 -0
  18. synlynk/board_governance.py +214 -0
  19. synlynk/brief.py +67 -0
  20. synlynk/canon.py +230 -0
  21. synlynk/capability.py +258 -0
  22. synlynk/capability_backfill.py +196 -0
  23. synlynk/capability_baseline.json +38 -0
  24. synlynk/capability_classifier.py +72 -0
  25. synlynk/capability_pack.py +173 -0
  26. synlynk/capability_probe.py +339 -0
  27. synlynk/capability_report.py +336 -0
  28. synlynk/capability_roles.py +19 -0
  29. synlynk/capability_sweep.py +694 -0
  30. synlynk/capability_watch.py +368 -0
  31. synlynk/capability_window.py +250 -0
  32. synlynk/charter_injection.py +114 -0
  33. synlynk/charter_schema.py +207 -0
  34. synlynk/charters.py +664 -0
  35. synlynk/ci_paths.py +47 -0
  36. synlynk/circuit_breaker.py +332 -0
  37. synlynk/cli.py +3991 -0
  38. synlynk/coldstart.py +788 -0
  39. synlynk/completion_tracker.py +127 -0
  40. synlynk/concierge.py +33 -0
  41. synlynk/config_schema.py +105 -0
  42. synlynk/connectors.py +109 -0
  43. synlynk/container_exec.py +199 -0
  44. synlynk/context.py +625 -0
  45. synlynk/context_compaction.py +189 -0
  46. synlynk/context_validator.py +30 -0
  47. synlynk/cost_audit.py +1071 -0
  48. synlynk/costs.py +1447 -0
  49. synlynk/daemon.py +1902 -0
  50. synlynk/db.py +5700 -0
  51. synlynk/db_schema.py +642 -0
  52. synlynk/discovery.py +120 -0
  53. synlynk/discovery_semantic.py +11 -0
  54. synlynk/dispatch.py +6199 -0
  55. synlynk/dispatch_pipeline.py +128 -0
  56. synlynk/doctor.py +1947 -0
  57. synlynk/events.py +527 -0
  58. synlynk/examples.py +13 -0
  59. synlynk/fencing.py +55 -0
  60. synlynk/first_win.py +36 -0
  61. synlynk/first_win_loop.py +73 -0
  62. synlynk/fleet.py +667 -0
  63. synlynk/fleet_templates.py +112 -0
  64. synlynk/gap_scanner.py +59 -0
  65. synlynk/gateway.py +162 -0
  66. synlynk/gc_cmd.py +144 -0
  67. synlynk/gh_role.py +67 -0
  68. synlynk/gh_shim.py +211 -0
  69. synlynk/gh_sync.py +389 -0
  70. synlynk/gh_sync_backfill.py +71 -0
  71. synlynk/gh_sync_cli.py +51 -0
  72. synlynk/gh_verify.py +579 -0
  73. synlynk/git_ref_lock.py +39 -0
  74. synlynk/github_app_auth.py +317 -0
  75. synlynk/github_repo.py +17 -0
  76. synlynk/goal_synthesizer.py +240 -0
  77. synlynk/governs_cli.py +403 -0
  78. synlynk/governs_compressed.py +87 -0
  79. synlynk/governs_engine.py +261 -0
  80. synlynk/governs_fsm.py +140 -0
  81. synlynk/governs_gate.py +191 -0
  82. synlynk/governs_resolver.py +72 -0
  83. synlynk/graph_cache.py +278 -0
  84. synlynk/greenfield_blueprints.py +214 -0
  85. synlynk/handover.py +217 -0
  86. synlynk/harness_adapters/__init__.py +1 -0
  87. synlynk/harness_adapters/agy.py +45 -0
  88. synlynk/harness_adapters/base.py +54 -0
  89. synlynk/harness_adapters/claude.py +42 -0
  90. synlynk/harness_adapters/codex.py +66 -0
  91. synlynk/harness_adapters/generic_api.py +71 -0
  92. synlynk/harness_adapters/grok.py +56 -0
  93. synlynk/harness_adapters/local.py +69 -0
  94. synlynk/harness_adapters/muse.py +65 -0
  95. synlynk/harness_adapters/registry.py +31 -0
  96. synlynk/harness_adapters/request.py +37 -0
  97. synlynk/heal.py +383 -0
  98. synlynk/heal_cycles.py +183 -0
  99. synlynk/heal_orphans.py +127 -0
  100. synlynk/hud.py +474 -0
  101. synlynk/identity_roles.py +52 -0
  102. synlynk/impact.py +325 -0
  103. synlynk/install.py +43 -0
  104. synlynk/install_manifest.py +83 -0
  105. synlynk/instructions.py +1276 -0
  106. synlynk/jev.py +100 -0
  107. synlynk/job_status_projection.py +362 -0
  108. synlynk/job_truth.py +655 -0
  109. synlynk/jobs.py +6843 -0
  110. synlynk/launch.py +190 -0
  111. synlynk/launch_dag.py +485 -0
  112. synlynk/launch_templates.py +353 -0
  113. synlynk/lifecycle.py +194 -0
  114. synlynk/lineage.py +238 -0
  115. synlynk/local_agent.py +859 -0
  116. synlynk/local_agent_seed.py +55 -0
  117. synlynk/local_http_auth.py +107 -0
  118. synlynk/local_pilot.py +409 -0
  119. synlynk/local_pilot_ledger.py +574 -0
  120. synlynk/local_pilot_runner.py +361 -0
  121. synlynk/logs.py +116 -0
  122. synlynk/maintainer.py +235 -0
  123. synlynk/marketing.py +592 -0
  124. synlynk/media.py +207 -0
  125. synlynk/merge_class.py +35 -0
  126. synlynk/merge_oracle.py +90 -0
  127. synlynk/mesh.py +579 -0
  128. synlynk/migrations/__init__.py +16 -0
  129. synlynk/migrations/m0016_cost_entries_pr_number.py +25 -0
  130. synlynk/migrations/m0017_daemon_job_purpose.py +33 -0
  131. synlynk/migrations/m0018_job_provenance_attestations.py +39 -0
  132. synlynk/migrations/m0019_policy_gate_events.py +25 -0
  133. synlynk/migrations/m0020_quota_snapshots.py +27 -0
  134. synlynk/migrations/m0021_harness_model_catalog.py +82 -0
  135. synlynk/migrations/m0022_subscriptions_harness_name.py +37 -0
  136. synlynk/migrations/m0023_cleanse_execution_log_models.py +45 -0
  137. synlynk/migrations/m0024_local_pilot_ledger.py +119 -0
  138. synlynk/migrations/m0025_local_pilot_review_selection.py +20 -0
  139. synlynk/migrations/m0026_local_pilot_resume_authorization.py +59 -0
  140. synlynk/migrations/m0027_local_pilot_resume_lifecycle_serialization.py +32 -0
  141. synlynk/migrations/m0028_deprecate_execution_log_models.py +39 -0
  142. synlynk/migrations/m0029_roadmap_arcs_milestone_eligibility.py +69 -0
  143. synlynk/migrations/m0030_github_sync_map.py +31 -0
  144. synlynk/migrations/m0031_quota_burn_rate_weights.py +21 -0
  145. synlynk/migrations/runner.py +75 -0
  146. synlynk/models.py +1131 -0
  147. synlynk/multirepo_graph.py +145 -0
  148. synlynk/notifiers/__init__.py +0 -0
  149. synlynk/notifiers/slack.py +48 -0
  150. synlynk/observatory.py +337 -0
  151. synlynk/onboarding_state.py +188 -0
  152. synlynk/pack.py +305 -0
  153. synlynk/packs/agency.yaml +1 -0
  154. synlynk/packs/software-product.yaml +1 -0
  155. synlynk/packs/studio.yaml +1 -0
  156. synlynk/parity.py +532 -0
  157. synlynk/platform_ops.py +1013 -0
  158. synlynk/platform_status.py +191 -0
  159. synlynk/pm_agent.py +288 -0
  160. synlynk/policy.py +379 -0
  161. synlynk/policy_cli.py +88 -0
  162. synlynk/policy_gates.py +111 -0
  163. synlynk/pr_check.py +75 -0
  164. synlynk/pr_multiplier.py +144 -0
  165. synlynk/pr_rebase.py +68 -0
  166. synlynk/preflight.py +225 -0
  167. synlynk/probe.py +1869 -0
  168. synlynk/probe_binding.py +182 -0
  169. synlynk/process_lifecycle.py +326 -0
  170. synlynk/product_store.py +322 -0
  171. synlynk/provenance.py +198 -0
  172. synlynk/py.typed +0 -0
  173. synlynk/pytest_shim.py +63 -0
  174. synlynk/qa_gate.py +151 -0
  175. synlynk/quota.py +1182 -0
  176. synlynk/quota_capture.py +676 -0
  177. synlynk/readiness.py +481 -0
  178. synlynk/rebase.py +452 -0
  179. synlynk/regen_guard.py +90 -0
  180. synlynk/registry.py +47 -0
  181. synlynk/relay.py +402 -0
  182. synlynk/relay_hub.py +1190 -0
  183. synlynk/release_authority.py +133 -0
  184. synlynk/release_marketing.py +460 -0
  185. synlynk/release_readme.py +600 -0
  186. synlynk/release_signals.py +168 -0
  187. synlynk/repo_classifier.py +111 -0
  188. synlynk/rollback.py +327 -0
  189. synlynk/runners/__init__.py +6 -0
  190. synlynk/runners/base.py +24 -0
  191. synlynk/runners/fly.py +69 -0
  192. synlynk/runners/local.py +74 -0
  193. synlynk/runners/manager.py +91 -0
  194. synlynk/runtime.py +30 -0
  195. synlynk/sandbox.py +65 -0
  196. synlynk/scan.py +2124 -0
  197. synlynk/scheduler.py +315 -0
  198. synlynk/selftest.py +1658 -0
  199. synlynk/sentinel.py +1274 -0
  200. synlynk/session.py +108 -0
  201. synlynk/sfia.py +192 -0
  202. synlynk/spike.py +124 -0
  203. synlynk/standalone_venv.py +101 -0
  204. synlynk/state_inventory.py +374 -0
  205. synlynk/state_registry.py +271 -0
  206. synlynk/state_repair.py +266 -0
  207. synlynk/status.py +536 -0
  208. synlynk/story_claims.py +25 -0
  209. synlynk/story_governance.py +68 -0
  210. synlynk/story_provisioning.py +233 -0
  211. synlynk/support_engineer.py +669 -0
  212. synlynk/surface.py +113 -0
  213. synlynk/swarm.py +73 -0
  214. synlynk/taxonomy.py +708 -0
  215. synlynk/taxonomy_standards.py +133 -0
  216. synlynk/team.py +1292 -0
  217. synlynk/testbed/__init__.py +4 -0
  218. synlynk/testbed/cli.py +207 -0
  219. synlynk/testbed/driver.py +281 -0
  220. synlynk/testbed/identities.py +101 -0
  221. synlynk/testbed/installer.py +148 -0
  222. synlynk/testbed/invariants.py +65 -0
  223. synlynk/testbed/scenarios.py +128 -0
  224. synlynk/testbed/state_db_soak.py +544 -0
  225. synlynk/tool_installer.py +175 -0
  226. synlynk/topology_discovery.py +300 -0
  227. synlynk/tpm_hooks.py +99 -0
  228. synlynk/tpm_sweep.py +157 -0
  229. synlynk/tui.py +166 -0
  230. synlynk/types_registry.py +149 -0
  231. synlynk/uninstall.py +34 -0
  232. synlynk/upgrade.py +388 -0
  233. synlynk/ux_nudges.py +30 -0
  234. synlynk/uxcore.py +778 -0
  235. synlynk/verify_effects.py +414 -0
  236. synlynk/version_policy.py +58 -0
  237. synlynk/viz/__init__.py +78 -0
  238. synlynk/viz/activity.py +313 -0
  239. synlynk/viz/architect.py +760 -0
  240. synlynk/viz/banner.py +26 -0
  241. synlynk/viz/board.py +231 -0
  242. synlynk/viz/cache.py +89 -0
  243. synlynk/viz/canvases.py +2607 -0
  244. synlynk/viz/chrome.py +68 -0
  245. synlynk/viz/constants.py +15 -0
  246. synlynk/viz/data.py +898 -0
  247. synlynk/viz/efficiency.py +1241 -0
  248. synlynk/viz/effort.py +427 -0
  249. synlynk/viz/format.py +59 -0
  250. synlynk/viz/gantt.py +1215 -0
  251. synlynk/viz/gantt_model.py +44 -0
  252. synlynk/viz/graphify.py +496 -0
  253. synlynk/viz/heartbeats.py +192 -0
  254. synlynk/viz/home.py +686 -0
  255. synlynk/viz/journeys.py +560 -0
  256. synlynk/viz/logical_expand.py +189 -0
  257. synlynk/viz/nav.py +78 -0
  258. synlynk/viz/observatory.py +360 -0
  259. synlynk/viz/onboarding.py +529 -0
  260. synlynk/viz/overview.py +786 -0
  261. synlynk/viz/ports.py +31 -0
  262. synlynk/viz/roles.py +49 -0
  263. synlynk/viz/server.py +952 -0
  264. synlynk/viz/source.py +62 -0
  265. synlynk/viz/write_routes.py +17 -0
  266. synlynk/viz_views.py +987 -0
  267. synlynk/vizor_daemon.py +1906 -0
  268. synlynk/vizor_ensure.py +22 -0
  269. synlynk/wal_ledger.py +156 -0
  270. synlynk/watch.py +63 -0
  271. synlynk/wave6.py +65 -0
  272. synlynk/wizard.py +1358 -0
  273. synlynk/workspace.py +94 -0
  274. synlynk/workspace_agent.py +54 -0
  275. synlynk/worktree.py +592 -0
  276. synlynk/worktree_lease.py +359 -0
  277. synlynk/worktree_prune.py +259 -0
  278. synlynk/worktree_sparse.py +238 -0
  279. synlynk-0.27.5.dist-info/METADATA +179 -0
  280. synlynk-0.27.5.dist-info/RECORD +284 -0
  281. synlynk-0.27.5.dist-info/WHEEL +5 -0
  282. synlynk-0.27.5.dist-info/entry_points.txt +2 -0
  283. synlynk-0.27.5.dist-info/licenses/LICENSE +21 -0
  284. synlynk-0.27.5.dist-info/top_level.txt +1 -0
synlynk/__init__.py ADDED
@@ -0,0 +1,3672 @@
1
+ #!/usr/bin/env python3
2
+ import sys
3
+ import os
4
+ import re
5
+ from typing import Optional, Tuple, Union
6
+ import sqlite3 as _sqlite3
7
+ import importlib as _importlib
8
+
9
+ from synlynk._constants import (
10
+ HARNESS_CAPABILITY_BASELINES,
11
+ HARNESS_TIMEOUT_PATTERNS,
12
+ QUOTA_PATTERNS,
13
+ VERSION,
14
+ _INSTALL_SCRIPT_URL,
15
+ NEXT_GEN_FLEET,
16
+ EXTENDED_FLEET,
17
+ )
18
+
19
+ __version__ = VERSION
20
+
21
+ _IS_TESTING = "pytest" in sys.modules or any("pytest" in str(arg) for arg in sys.argv)
22
+ _FAST_CLI = not _IS_TESTING and (
23
+ os.environ.get("SYNLYNK_CLI_ENTRYPOINT") == "1" or (
24
+ os.path.basename(sys.argv[0]) in {"synlynk", "synlynk.py"}
25
+ or (os.path.basename(sys.argv[0]) == "__main__.py" and "synlynk" in str(sys.argv[0]))
26
+ ) or any(flag in sys.argv[1:] for flag in ("-h", "--help", "--version"))
27
+ )
28
+
29
+
30
+ def _vizor_daemon_process() -> bool:
31
+ """True only for the machine-scoped Vizor daemon or its ensure tick.
32
+
33
+ Those processes start with cwd set to the daemon home, which is not a git
34
+ checkout. Skipping product-root resolution here must not weaken gh:#1831
35
+ for any other import.
36
+ """
37
+ if os.environ.get("SYNLYNK_VIZOR_DAEMON") == "1":
38
+ return True
39
+ # python -m synlynk.vizor_daemon sets argv[0] to the module file, so the
40
+ # basename covers that invocation. A later argv entry that only names the
41
+ # module is not this process (gh:#1831).
42
+ names = {"vizor_daemon.py", "vizor_ensure.py"}
43
+ argv0 = os.path.basename(sys.argv[0]) if sys.argv else ""
44
+ return argv0 in names
45
+
46
+ if not _FAST_CLI:
47
+ import argparse
48
+ from pathlib import Path
49
+ from synlynk.db import _try_sync_arc_by_version
50
+
51
+ # The compatibility surface below historically relied on these names being
52
+ # available from the package module. Keep that behavior for imports/tests,
53
+ # but do not pay for the modules on the subprocess CLI fast path.
54
+ if not _FAST_CLI:
55
+ import json
56
+ import shutil
57
+ import subprocess
58
+ import tempfile
59
+ import threading
60
+ import time
61
+ import urllib.request
62
+
63
+
64
+ _LEGACY_MODULES = (
65
+ "upgrade", "sentinel", "probe", "fencing", "dispatch", "quota", "costs",
66
+ "capability_roles", "taxonomy", "doctor", "team", "heal", "support_engineer",
67
+ "backlog", "backlog_extractor", "charter_injection", "context", "jobs",
68
+ "story_provisioning", "daemon", "instructions", "scan", "hud", "platform_status",
69
+ "logs", "wizard", "launch", "circuit_breaker", "capability_probe", "wal_ledger",
70
+ "worktree_lease", "governs_compressed",
71
+ )
72
+
73
+
74
+ def _load_legacy_imports():
75
+ """Load the historical package-level exports on demand.
76
+
77
+ The package has long re-exported nearly every CLI implementation symbol.
78
+ Keep that API, but avoid importing the entire command graph while argparse
79
+ is only constructing or displaying the parser.
80
+ """
81
+ # Restore module globals used by the older package-level helpers when a
82
+ # non-observability command leaves the fast CLI path.
83
+ for module_name in ("json", "shutil", "subprocess", "tempfile", "threading", "time"):
84
+ module = _importlib.import_module(module_name)
85
+ globals()[module_name] = module
86
+ globals()["urllib"] = _importlib.import_module("urllib")
87
+ _importlib.import_module("urllib.request")
88
+ launch_templates = _importlib.import_module("synlynk.launch_templates")
89
+ db_schema = _importlib.import_module("synlynk.db_schema")
90
+ globals()["LAUNCH_TASK_TEMPLATES"] = launch_templates.LAUNCH_TASK_TEMPLATES
91
+ globals()["_DB_SCHEMA"] = db_schema._DB_SCHEMA
92
+ globals()["_DB_SCORES_VIEW"] = db_schema._DB_SCORES_VIEW
93
+
94
+ for module_name in _LEGACY_MODULES:
95
+ module = _importlib.import_module(f"synlynk.{module_name}")
96
+ globals().update({
97
+ name: value
98
+ for name, value in vars(module).items()
99
+ if not name.startswith("__") and name not in globals()
100
+ })
101
+ # Importing a submodule also installs it as a package attribute. Restore
102
+ # the historical function export where the names intentionally collide.
103
+ globals()["upgrade"] = _importlib.import_module("synlynk.upgrade").upgrade
104
+ globals()["cmd_ecosystem_status"] = _importlib.import_module("synlynk.status").cmd_status
105
+ globals()["cmd_viz"] = _importlib.import_module("synlynk.viz").cmd_viz
106
+ globals()["cmd_schedule"] = _importlib.import_module("synlynk.scheduler").cmd_schedule
107
+ globals()["main"], globals()["cmd_watch"] = (
108
+ _importlib.import_module("synlynk.cli").main,
109
+ _importlib.import_module("synlynk.cli").cmd_watch,
110
+ )
111
+ db = _importlib.import_module("synlynk.db")
112
+ globals().update({
113
+ name: value
114
+ for name, value in vars(db).items()
115
+ if not name.startswith("__") and name not in globals()
116
+ })
117
+
118
+
119
+ def main(argv=None):
120
+ """Lazy compatibility entry point for installed console scripts."""
121
+ cli_tokens = list(argv) if argv is not None else sys.argv[1:]
122
+ if _FAST_CLI and cli_tokens and cli_tokens[0] in {"jobs", "status"}:
123
+ from synlynk._fast_cli import run_fast_command
124
+
125
+ if run_fast_command(cli_tokens) is True:
126
+ return None
127
+ return _importlib.import_module("synlynk.cli").main(argv)
128
+
129
+
130
+ def __getattr__(name):
131
+ """Lazily preserve compatibility exports removed from the fast path."""
132
+ if name in {"LAUNCH_TASK_TEMPLATES", "_DB_SCHEMA", "_DB_SCORES_VIEW"}:
133
+ module_name = "synlynk.launch_templates" if name == "LAUNCH_TASK_TEMPLATES" else "synlynk.db_schema"
134
+ value = getattr(_importlib.import_module(module_name), name)
135
+ globals()[name] = value
136
+ return value
137
+ raise AttributeError(name)
138
+
139
+ CYCLE_COLORS = {
140
+ "dream": "#a78bfa",
141
+ "design": "#60a5fa",
142
+ "plan": "#34d399",
143
+ "build": "#fbbf24",
144
+ "ship": "#f87171",
145
+ "sustain": "#94a3b8",
146
+ }
147
+
148
+ CYCLE_DESCRIPTIONS = {
149
+ "dream": "What's worth building\nIdeate, assess, identify opportunities\n",
150
+ "design": "Brainstorm -> spec -> UX\nTurn ideas into a concrete brief\n",
151
+ "plan": "Implementation plan, story breakdown, agent wave schedule\n",
152
+ "build": "Dispatch agents, run jobs, iterate on diffs\n",
153
+ "ship": "Cut release, changelog, publish\n",
154
+ "sustain": "Monitor, patch, community, docs, support\n",
155
+ }
156
+
157
+ CYCLE_DEFAULT_AGENTS = {
158
+ "dream": ["claude"],
159
+ "design": ["claude"],
160
+ "plan": ["claude"],
161
+ "build": ["agy", "codex", "grok"],
162
+ "ship": ["claude"],
163
+ "sustain": ["claude", "agy", "codex", "grok"],
164
+ }
165
+
166
+ CORE_TEMPLATE_IDS = {"arch-review", "product-assessment", "lifecycle-setup"}
167
+
168
+ # Capability scores within this gap are considered ties → break on cost (#140).
169
+ _CAPABILITY_COST_TIE_GAP = 0.15
170
+
171
+ def _launch_visible_template_ids() -> set:
172
+ tier1_primary_ids = {
173
+ entry["command"]
174
+ for entry in entries_for_tier(1)
175
+ if entry["prominence"] == "primary"
176
+ }
177
+ return CORE_TEMPLATE_IDS | tier1_primary_ids
178
+
179
+
180
+ def _launch_visible_templates() -> list:
181
+ from synlynk.launch_templates import LAUNCH_TASK_TEMPLATES
182
+
183
+ visible_ids = _launch_visible_template_ids()
184
+ return [template for template in LAUNCH_TASK_TEMPLATES if template["id"] in visible_ids]
185
+
186
+
187
+ def _template_matches(template: dict, scan: dict) -> bool:
188
+ """Returns True if the template's trigger condition is met by scan."""
189
+ condition = template.get("trigger_condition")
190
+ if condition is None:
191
+ return True
192
+ try:
193
+ return bool(condition(scan))
194
+ except Exception:
195
+ return False
196
+
197
+
198
+ def _select_launch_tasks(scan: dict) -> list:
199
+ """Returns ordered list of 3-5 matching templates (core first, bonus sorted by specificity)."""
200
+ eligible = [t for t in _launch_visible_templates() if _template_matches(t, scan)]
201
+ core = [t for t in eligible if t["id"] in CORE_TEMPLATE_IDS]
202
+ bonus = [t for t in eligible if t["id"] not in CORE_TEMPLATE_IDS]
203
+ return (core + bonus)[:5]
204
+
205
+
206
+ def _render_prompt(template: dict, scan: dict) -> str:
207
+ """Substitutes {variables} in prompt_template from scan data. Missing vars become ''."""
208
+ import datetime as _datetime
209
+ import re as _re
210
+
211
+ repos = scan.get("repos", [])
212
+ primary = repos[0] if repos else {}
213
+ tests = scan.get("tests") or {}
214
+ source = scan.get("source") or []
215
+ complexity = scan.get("complexity") or {}
216
+ git = scan.get("git") or {}
217
+ gap_functions = tests.get("gap_functions", [])
218
+ if gap_functions:
219
+ gap_functions_text = ", ".join(
220
+ g.get("name", str(g)) if isinstance(g, dict) else str(g)
221
+ for g in gap_functions
222
+ )
223
+ else:
224
+ gap_functions_text = "none"
225
+ typed_pct = (
226
+ int(sum(f.get("typed_pct", 0) for f in source) / max(len(source), 1))
227
+ if source
228
+ else int(scan.get("typed_pct", 0) or 0)
229
+ )
230
+ largest_file = max(source, key=lambda f: f.get("lines", 0), default={})
231
+ largest_fn = {}
232
+ for file_info in source:
233
+ for fn in file_info.get("largest_fns", []):
234
+ if fn.get("lines", 0) > largest_fn.get("lines", 0):
235
+ largest_fn = {
236
+ "name": fn.get("name", ""),
237
+ "lines": fn.get("lines", 0),
238
+ "file": file_info.get("path", ""),
239
+ }
240
+ hotspots = complexity.get("hotspots", [])
241
+ top_hotspot = max(hotspots, key=lambda h: h.get("lines", 0), default={})
242
+ churn = git.get("churn", [])
243
+ hot_file = churn[0].get("path", "") if churn else ""
244
+ commit_count = churn[0].get("commits", 0) if churn else 0
245
+ variables = {
246
+ "workspace": scan.get("workspace_name", ""),
247
+ "stack": ", ".join(primary.get("stack_labels", [])) or "unknown",
248
+ "repo_name": primary.get("name", ""),
249
+ "topology": scan.get("topology", "single"),
250
+ "test_count": str(scan.get("test_ratio", 0)),
251
+ "gap_functions": gap_functions_text,
252
+ "typed_pct": str(typed_pct),
253
+ "largest_file": largest_file.get("path", ""),
254
+ "largest_file_lines": str(largest_file.get("lines", 0)),
255
+ "largest_file_fns": str(largest_file.get("functions", 0)),
256
+ "largest_fn": largest_fn.get("name", ""),
257
+ "largest_fn_lines": str(largest_fn.get("lines", 0)),
258
+ "top_hotspot": top_hotspot.get("fn") or os.path.basename(top_hotspot.get("path", "")),
259
+ "top_hotspot_lines": str(top_hotspot.get("lines", 0)),
260
+ "hot_file": hot_file,
261
+ "commit_count": str(commit_count),
262
+ "date": _datetime.date.today().isoformat(),
263
+ "agent": template.get("agent", "claude"),
264
+ }
265
+ text = template.get("prompt_template", "")
266
+
267
+ def _replace(match):
268
+ key = match.group(1)
269
+ return variables.get(key, "")
270
+
271
+ return _re.sub(r"\{(\w+)\}", _replace, text)
272
+
273
+
274
+ TASK_STATUSES = {
275
+ "[ ]": "active",
276
+ "[x]": "done",
277
+ "[-]": "deferred",
278
+ "[~]": "superseded",
279
+ "[>]": "absorbed",
280
+ }
281
+
282
+
283
+ def _project_root() -> str:
284
+ """Return the shared repo root for the current git worktree.
285
+
286
+ Raises RuntimeError if git-common-dir resolution fails, unless
287
+ SYNLYNK_ALLOW_CWD_FALLBACK=1 is set (see gh:#1831 — a silent CWD
288
+ fallback here previously minted duplicate per-directory legacy
289
+ state.db shards for the same project).
290
+ """
291
+ import subprocess
292
+
293
+ try:
294
+ common = subprocess.check_output(
295
+ ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
296
+ stderr=subprocess.DEVNULL,
297
+ ).decode().strip()
298
+ if common:
299
+ return os.path.abspath(os.path.join(common, ".."))
300
+ except Exception as exc:
301
+ if os.environ.get("SYNLYNK_ALLOW_CWD_FALLBACK") == "1":
302
+ return os.getcwd()
303
+ raise RuntimeError(
304
+ "could not resolve git-common-dir; refusing to silently fall back "
305
+ "to the current working directory (this previously minted stray "
306
+ "per-directory state shards — see gh:#1831). Set "
307
+ "SYNLYNK_ALLOW_CWD_FALLBACK=1 to override."
308
+ ) from exc
309
+ return os.getcwd()
310
+
311
+
312
+ def _working_tree_root() -> str:
313
+ """Return the root of the current checkout, not its shared Git directory."""
314
+ import subprocess
315
+
316
+ try:
317
+ root = subprocess.check_output(
318
+ ["git", "rev-parse", "--show-toplevel"],
319
+ stderr=subprocess.DEVNULL,
320
+ ).decode().strip()
321
+ if root:
322
+ return os.path.abspath(root)
323
+ raise ValueError("git returned an empty worktree root")
324
+ except Exception as exc:
325
+ if os.environ.get("SYNLYNK_ALLOW_CWD_FALLBACK") == "1":
326
+ return os.getcwd()
327
+ raise RuntimeError(
328
+ "could not resolve git worktree root; refusing to write generated "
329
+ "project docs relative to an unknown checkout"
330
+ ) from exc
331
+
332
+
333
+ def _get_project_root() -> str:
334
+ """Backwards-compatible alias for callers that expect a root helper."""
335
+ return _project_root()
336
+
337
+
338
+ def _resolve_db_path() -> str:
339
+ """Resolve the product graph, migrating the legacy path once if needed."""
340
+ if os.environ.get("SYNLYNK_STATE_DB_PATH"):
341
+ return os.environ["SYNLYNK_STATE_DB_PATH"]
342
+
343
+ from synlynk.product_store import identity_slug_from_config, migrate_state_db_if_needed, state_db_path
344
+ from synlynk.state_registry import canonical_path
345
+
346
+ root = _project_root()
347
+ slug = identity_slug_from_config(root)
348
+ try:
349
+ path = canonical_path(
350
+ slug,
351
+ state_db_path(slug),
352
+ allow_unregistered_existing=os.environ.get("SYNLYNK_ALLOW_REGISTRY_RECOVERY") == "1",
353
+ )
354
+ except Exception as exc:
355
+ if "no canonical registry entry" in str(exc):
356
+ if not os.path.exists(os.path.join(root, ".synlynk", "config.json")):
357
+ return str(state_db_path(slug))
358
+ raise type(exc)(
359
+ f"{exc}; run SYNLYNK_ALLOW_REGISTRY_RECOVERY=1 synlynk state register "
360
+ f"--slug {slug} --path {state_db_path(slug)}"
361
+ ) from exc
362
+ raise
363
+ if not _IS_TESTING:
364
+ try:
365
+ migrate_state_db_if_needed(root)
366
+ except (PermissionError, OSError):
367
+ pass
368
+ return str(path)
369
+
370
+
371
+ def _is_git_worktree() -> bool:
372
+ """Return whether the current directory is a linked git worktree."""
373
+ import subprocess
374
+
375
+ try:
376
+ git_dir_output = subprocess.check_output(
377
+ ["git", "rev-parse", "--path-format=absolute", "--git-dir"],
378
+ stderr=subprocess.DEVNULL,
379
+ )
380
+ common_dir_output = subprocess.check_output(
381
+ ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
382
+ stderr=subprocess.DEVNULL,
383
+ )
384
+ git_dir = git_dir_output.decode() if isinstance(git_dir_output, bytes) else str(git_dir_output)
385
+ common_dir = common_dir_output.decode() if isinstance(common_dir_output, bytes) else str(common_dir_output)
386
+ git_dir = git_dir.strip()
387
+ common_dir = common_dir.strip()
388
+ return os.path.realpath(git_dir) != os.path.realpath(common_dir)
389
+ except (OSError, subprocess.CalledProcessError):
390
+ return False
391
+
392
+
393
+ # Resolve once at import time so ordinary test doubles for subprocess calls do
394
+ # not accidentally change DB-path safety decisions mid-test.
395
+ # The linked-worktree guard only protects pytest from accidentally touching the
396
+ # shared product ledger. The CLI fast path has no pytest state to isolate and
397
+ # must not pay for two git subprocesses before a read-only command starts.
398
+ _INITIAL_GIT_WORKTREE = False if (_FAST_CLI or _vizor_daemon_process()) else _is_git_worktree()
399
+
400
+
401
+ def _test_isolation_db_path() -> str:
402
+ """Return a process-local DB path for pytest worktree execution."""
403
+ import hashlib
404
+ import tempfile
405
+
406
+ key = hashlib.md5(os.path.abspath(os.getcwd()).encode()).hexdigest()[:12]
407
+ return os.path.join(tempfile.gettempdir(), "synlynk-test-db", f"{key}-{os.getpid()}.db")
408
+
409
+
410
+ def _should_isolate_worktree_db(db_path: str) -> bool:
411
+ """Limit the worktree guard to test execution unless explicitly disabled."""
412
+ if os.environ.get("SYNLYNK_ALLOW_SHARED_STATE_DB") == "1":
413
+ return False
414
+ if not (_INITIAL_GIT_WORKTREE and os.environ.get("PYTEST_CURRENT_TEST")):
415
+ return False
416
+ # Explicit test DB_PATH overrides (including tmp_path fixtures) are already
417
+ # isolated and must keep working in a worktree. Only the canonical product
418
+ # ledger location is guarded.
419
+ canonical_root = os.path.abspath(os.path.expanduser("~/.synlynk/projects"))
420
+ return os.path.abspath(db_path).startswith(canonical_root + os.sep)
421
+
422
+
423
+ # The Vizor daemon home is not a checkout. Point DB_PATH at devnull so import
424
+ # cannot mint a product ledger there (gh:#1831). Nothing in the daemon opens it.
425
+ if _FAST_CLI:
426
+ DB_PATH = os.environ.get("SYNLYNK_STATE_DB_PATH")
427
+ elif _vizor_daemon_process():
428
+ DB_PATH = os.devnull
429
+ else:
430
+ DB_PATH = _resolve_db_path()
431
+ # The configured path is intentionally distinct from the path selected by the
432
+ # last successful connection. Away sandboxes may need the local fallback.
433
+ ACTIVE_DB_PATH = None
434
+
435
+
436
+ def _migrate_db_with_schema_retry(conn: _sqlite3.Connection) -> None:
437
+ """Retry the one SQLite error caused by a concurrent schema refresh.
438
+
439
+ Two short-lived CLI connections can both bootstrap an older state DB. One
440
+ connection may commit an additive migration after the other prepared a
441
+ schema statement, which SQLite reports as ``database schema has changed``.
442
+ The migration is idempotent; retrying it refreshes that connection's schema
443
+ cache. Other operational failures remain fail-closed.
444
+ """
445
+ from synlynk.db import _migrate_db
446
+
447
+ for attempt in range(3):
448
+ try:
449
+ _migrate_db(conn)
450
+ return
451
+ except _sqlite3.OperationalError as exc:
452
+ if "database schema has changed" not in str(exc).lower() or attempt == 2:
453
+ raise
454
+ try:
455
+ conn.rollback()
456
+ except _sqlite3.Error:
457
+ pass
458
+
459
+
460
+ def migrate_cost_entries_for_compaction(db_path: str) -> None:
461
+ conn = _sqlite3.connect(db_path)
462
+ try:
463
+ existing_cols = {row[1] for row in conn.execute("PRAGMA table_info(cost_entries)")}
464
+ if "compaction_event_type" not in existing_cols:
465
+ conn.execute("ALTER TABLE cost_entries ADD COLUMN compaction_event_type TEXT")
466
+ conn.commit()
467
+ finally:
468
+ conn.close()
469
+
470
+
471
+ def log_cost_entry(db_path: str, harness: str, tokens_in: int, tokens_out: int,
472
+ total_cost_usd: float, compaction_event_type=None) -> None:
473
+ """Record a compaction-related cost row through the single sanctioned
474
+ cost_entries write path (db.py's _insert_cost_row), not raw SQL here."""
475
+ import datetime
476
+ from synlynk.db import _insert_cost_row
477
+
478
+ _insert_cost_row(
479
+ session_date=datetime.date.today().isoformat(),
480
+ agent=harness,
481
+ harness=harness,
482
+ input_tokens=tokens_in,
483
+ output_tokens=tokens_out,
484
+ total_cost_usd=total_cost_usd,
485
+ compaction_event_type=compaction_event_type,
486
+ db_path=db_path,
487
+ )
488
+
489
+
490
+
491
+
492
+ def _get_db(
493
+ db_path: str = None,
494
+ read_only: bool = False,
495
+ *,
496
+ migrate: bool = True,
497
+ ) -> _sqlite3.Connection:
498
+ """Return a SQLite connection to state.db.
499
+
500
+ ``read_only=True`` opens the selected ledger through SQLite's read-only
501
+ URI mode and deliberately skips directory creation, WAL setup, and
502
+ migrations. Inspection callers use this mode so status cannot mutate or
503
+ silently substitute the product ledger.
504
+
505
+ SYNLYNK_STATE_DB_PATH, if set, is used verbatim and takes precedence over
506
+ everything below: no nested-worktree guard, no fallback chain. A caller
507
+ setting this env var has made a deliberate choice about ledger location
508
+ (e.g. a sandbox restricted to the repo workspace root, where neither the
509
+ home path nor the tmpdir fallback is reachable). If that path is itself
510
+ unwritable, the resulting exception propagates uncaught — an explicit
511
+ override that fails should surface loudly, not be silently re-routed.
512
+ See #681.
513
+
514
+ Fallback to ./.synlynk/state.db is disabled by default. A local ledger is
515
+ a different source of truth and must never be selected silently when the
516
+ product ledger is unavailable. Sandboxed callers that explicitly accept
517
+ an isolated, non-canonical ledger may set
518
+ SYNLYNK_ALLOW_STATE_DB_FALLBACK=1. Dispatched-agent sandboxes commonly
519
+ mount $HOME read-only; that surfaces as OSError(EROFS) from os.makedirs
520
+ (not PermissionError) or as sqlite3.OperationalError from connect when
521
+ the directory already exists. See #648 and the Sev1 state-integrity
522
+ incident #1655.
523
+
524
+ Primary product ledger must not live under job/feature worktrees when the
525
+ home path is the intended path (#330 / fleet S2a). If an operator
526
+ explicitly enables the sandbox fallback, it uses a path that never lands
527
+ under worktrees (tmpdir when cwd is a job/feature worktree) so the
528
+ nested_state matrix stays clean.
529
+
530
+ During pytest execution from a linked worktree, the canonical shared path is
531
+ redirected to a process-local temporary DB to protect the live ledger. Set
532
+ SYNLYNK_ALLOW_SHARED_STATE_DB=1 only for tests that explicitly exercise
533
+ shared-DB behavior.
534
+ """
535
+ global ACTIVE_DB_PATH, DB_PATH
536
+ ACTIVE_DB_PATH = None
537
+
538
+ if DB_PATH is None:
539
+ DB_PATH = _resolve_db_path()
540
+
541
+ def _check_write_capability(path: str) -> None:
542
+ """Check access to *path* without opening SQLite or changing it."""
543
+ import tempfile
544
+
545
+ parent = os.path.dirname(path) or "."
546
+ if os.path.exists(path):
547
+ fd = os.open(path, os.O_RDWR)
548
+ os.close(fd)
549
+ return
550
+
551
+ # A new database has no file to open yet. Probe the parent directory
552
+ # with a disposable file instead of creating the candidate database
553
+ # before its write capability has been established.
554
+ fd, probe = tempfile.mkstemp(dir=parent, prefix=".synlynk-write-probe-")
555
+ os.close(fd)
556
+ os.unlink(probe)
557
+
558
+ def _connect(path: str, *, read_only: bool = False, migrate: bool = True) -> _sqlite3.Connection:
559
+ global ACTIVE_DB_PATH
560
+ if read_only:
561
+ wal_path = f"{path}-wal"
562
+ has_wal = os.path.exists(wal_path) and os.path.getsize(wal_path) > 0
563
+ if has_wal:
564
+ # An immutable connection intentionally ignores WAL frames and
565
+ # would expose stale state. Take a consistent online-backup
566
+ # snapshot into memory instead; callers still get a read-only
567
+ # inspection connection without mutating the source ledger.
568
+ source_conn = _sqlite3.connect(
569
+ f"file:{os.path.abspath(path)}?mode=ro", uri=True, timeout=30.0
570
+ )
571
+ conn = _sqlite3.connect(":memory:")
572
+ try:
573
+ source_conn.backup(conn)
574
+ finally:
575
+ source_conn.close()
576
+ else:
577
+ conn = _sqlite3.connect(
578
+ f"file:{os.path.abspath(path)}?mode=ro&immutable=1",
579
+ uri=True,
580
+ timeout=30.0,
581
+ )
582
+ try:
583
+ conn.execute("PRAGMA busy_timeout=30000")
584
+ conn.execute("PRAGMA foreign_keys=ON")
585
+ quick_check = conn.execute("PRAGMA quick_check").fetchone()[0]
586
+ if quick_check != "ok":
587
+ raise _sqlite3.DatabaseError(
588
+ f"state database failed quick_check: {quick_check}"
589
+ )
590
+ except Exception:
591
+ conn.close()
592
+ raise
593
+ ACTIVE_DB_PATH = os.path.abspath(path)
594
+ return conn
595
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
596
+ _check_write_capability(path)
597
+ conn = _sqlite3.connect(path, timeout=30.0)
598
+ try:
599
+ conn.isolation_level = None
600
+ # Do this before WAL setup or migrations. BEGIN IMMEDIATE only
601
+ # acquires a write reservation; rolling it back leaves the
602
+ # candidate database and its sidecars byte-identical on failure.
603
+ conn.execute("BEGIN IMMEDIATE")
604
+ conn.execute("ROLLBACK")
605
+ from synlynk.wal_ledger import ensure_wal_pragmas
606
+ ensure_wal_pragmas(conn)
607
+ if migrate:
608
+ _migrate_db_with_schema_retry(conn)
609
+ if migrate and not _IS_TESTING and os.path.abspath(path) == os.path.abspath(DB_PATH):
610
+ from synlynk.product_store import identity_slug_from_config
611
+ from synlynk.state_registry import ensure_registered_product, identity_metadata, product_identity
612
+
613
+ slug = identity_slug_from_config(_project_root())
614
+ repo_root = Path(_project_root())
615
+ entry = ensure_registered_product(
616
+ slug,
617
+ Path(path),
618
+ product_identity(slug, _project_root()),
619
+ repo_path=repo_root,
620
+ )
621
+ if entry.get("repo_path") != str(repo_root.resolve()):
622
+ from synlynk.state_registry import update_registered_product
623
+
624
+ update_registered_product(slug, repo_path=str(repo_root.resolve()))
625
+ identity_metadata(conn, product_id=entry["product_id"], mode="canonical", path=Path(path))
626
+ conn.commit()
627
+ except Exception:
628
+ conn.close()
629
+ raise
630
+ ACTIVE_DB_PATH = os.path.abspath(path)
631
+ return conn
632
+
633
+ if db_path is not None:
634
+ return _connect(db_path, read_only=read_only, migrate=migrate)
635
+
636
+ override = os.environ.get("SYNLYNK_STATE_DB_PATH")
637
+ if override:
638
+ # Explicit overrides remain authoritative: failures propagate and do
639
+ # not silently select another ledger.
640
+ return _connect(override, read_only=read_only, migrate=migrate)
641
+
642
+ if read_only:
643
+ # Read-only inspection must fail on an unavailable/corrupt canonical
644
+ # ledger rather than falling back to a stale repo or sandbox copy.
645
+ return _connect(DB_PATH, read_only=True, migrate=migrate)
646
+
647
+ from synlynk.fleet import assert_not_nested_product_ledger, sandbox_fallback_db_path
648
+
649
+ db_path = DB_PATH
650
+ if _should_isolate_worktree_db(db_path):
651
+ db_path = _test_isolation_db_path()
652
+ print(
653
+ "warning: pytest is running from a git worktree; using isolated "
654
+ f"state DB at {db_path}. Set SYNLYNK_ALLOW_SHARED_STATE_DB=1 only "
655
+ "for an intentional shared-DB test.",
656
+ file=sys.stderr,
657
+ )
658
+ try:
659
+ assert_not_nested_product_ledger(db_path, home_writable=True)
660
+ return _connect(db_path, migrate=migrate)
661
+ # RuntimeError from nested-ledger refusal must not trigger fallback.
662
+ except (OSError, _sqlite3.OperationalError) as exc:
663
+ if os.environ.get("SYNLYNK_ALLOW_STATE_DB_FALLBACK") != "1":
664
+ raise RuntimeError(
665
+ "canonical state DB unavailable or unusable at "
666
+ f"{os.path.abspath(db_path)}: {exc}; refusing to select a "
667
+ "non-canonical fallback. Set SYNLYNK_STATE_DB_PATH to an "
668
+ "explicit verified ledger, or set "
669
+ "SYNLYNK_ALLOW_STATE_DB_FALLBACK=1 only for an intentional "
670
+ "isolated sandbox."
671
+ ) from exc
672
+ fallback_path = sandbox_fallback_db_path()
673
+ print(
674
+ f"warning: cannot open canonical project state DB at {db_path} "
675
+ f"({exc}); explicitly falling back with "
676
+ "SYNLYNK_ALLOW_STATE_DB_FALLBACK=1 — using isolated "
677
+ f"ledger {fallback_path}",
678
+ file=sys.stderr,
679
+ )
680
+ return _connect(fallback_path, migrate=migrate)
681
+
682
+
683
+ def get_state_db_path() -> str:
684
+ """Return the effective state DB path, including the last fallback choice."""
685
+ return os.path.abspath(
686
+ os.environ.get("SYNLYNK_STATE_DB_PATH") or ACTIVE_DB_PATH or DB_PATH
687
+ )
688
+
689
+
690
+ def open_state_db(
691
+ *,
692
+ product_id: str | None = None,
693
+ mode: str = "canonical",
694
+ read_only: bool = False,
695
+ explicit_path: str | None = None,
696
+ ) -> _sqlite3.Connection:
697
+ """Open a state ledger through an explicit, typed access mode.
698
+
699
+ ``canonical`` is the only mode used by normal product commands. The
700
+ other modes are intentionally explicit so tests, backup verification, and
701
+ restore tooling cannot accidentally become an alternate product ledger.
702
+ Degraded ledgers are read-only unless an operator explicitly opts into a
703
+ write with ``SYNLYNK_ALLOW_DEGRADED_WRITES=1``.
704
+ """
705
+ allowed = {"canonical", "test", "backup", "restore", "degraded"}
706
+ if mode not in allowed:
707
+ raise ValueError(f"unsupported state DB mode: {mode!r}")
708
+ if mode == "canonical":
709
+ if explicit_path is not None:
710
+ from synlynk.product_store import identity_slug_from_config, state_db_path
711
+ from synlynk.state_registry import canonical_path
712
+
713
+ slug = identity_slug_from_config(_project_root())
714
+ expected = canonical_path(slug, state_db_path(slug)).resolve()
715
+ requested = Path(explicit_path).expanduser().resolve()
716
+ if requested != expected:
717
+ raise RuntimeError(
718
+ f"canonical state DB path mismatch: expected {expected}, requested {requested}"
719
+ )
720
+ return _get_db(db_path=explicit_path, read_only=read_only) if explicit_path else _get_db(read_only=read_only)
721
+ if explicit_path is None:
722
+ raise ValueError(f"mode={mode!r} requires explicit_path")
723
+ if mode == "degraded" and not read_only and os.environ.get("SYNLYNK_ALLOW_DEGRADED_WRITES") != "1":
724
+ raise RuntimeError(
725
+ "degraded state DB access is read-only by default; set "
726
+ "SYNLYNK_ALLOW_DEGRADED_WRITES=1 for an intentional write"
727
+ )
728
+ return _get_db(db_path=explicit_path, read_only=read_only or mode in {"backup"})
729
+
730
+
731
+
732
+ def _is_migrated() -> bool:
733
+ return os.path.exists(os.path.join(_project_root(), ".synlynk", ".synlynk_migrated"))
734
+
735
+
736
+ _GENERATED_DOC_FILES = ("roadmap.md", "todo.md", "memory.md", "costs.md")
737
+
738
+
739
+ def _generated_docs_locked(replace_generated_docs: bool = False, root: str = None) -> bool:
740
+ """True when init/brownfield must not raw-write the generated 4-docs.
741
+
742
+ Checks the given root and its git-common-dir parent so a linked worktree
743
+ of a migrated product is locked even if the worktree has no local marker.
744
+ """
745
+ if replace_generated_docs:
746
+ return False
747
+ check_roots = []
748
+ if root:
749
+ check_roots.append(os.path.abspath(root))
750
+ try:
751
+ common = subprocess.check_output(
752
+ ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
753
+ cwd=root,
754
+ stderr=subprocess.DEVNULL,
755
+ ).decode().strip()
756
+ if common:
757
+ check_roots.append(os.path.abspath(os.path.join(common, "..")))
758
+ except Exception:
759
+ pass
760
+ else:
761
+ check_roots.append(_project_root())
762
+ check_roots.append(os.getcwd())
763
+ seen = set()
764
+ for r in check_roots:
765
+ real = os.path.realpath(r)
766
+ if real in seen:
767
+ continue
768
+ seen.add(real)
769
+ if os.path.exists(os.path.join(real, ".synlynk", ".synlynk_migrated")):
770
+ return True
771
+ return False
772
+
773
+
774
+ def _synlynk_project_docs_dir() -> str:
775
+ return os.path.join(_project_root(), ".synlynk", "project-docs")
776
+
777
+
778
+ def _dr_sync(relative_path: str) -> None:
779
+ if not _generated_doc_side_effects_allowed():
780
+ return
781
+
782
+ from synlynk.db import _resolved_docs_dir
783
+
784
+ # Root resolution is mandatory even when the DR destination is absolute:
785
+ # the source document must belong to the invoking checkout.
786
+ root = _working_tree_root()
787
+ src = os.path.join(_resolved_docs_dir(), relative_path)
788
+ try:
789
+ workspace_path = os.path.join(root, '.synlynk', 'workspace.json')
790
+ legacy_path = os.path.join(root, '.synlynk', 'config.json')
791
+ cfg_path = workspace_path if os.path.exists(workspace_path) else legacy_path
792
+ if not os.path.exists(cfg_path):
793
+ return
794
+ with open(cfg_path) as f:
795
+ cfg = json.load(f)
796
+ dr_path = cfg.get('dr_sync_path')
797
+ if not dr_path:
798
+ return
799
+ dr_path = os.path.expanduser(str(dr_path))
800
+ except Exception:
801
+ pass
802
+ return
803
+ if not os.path.isabs(dr_path):
804
+ dr_path = os.path.abspath(os.path.join(root, dr_path))
805
+ try:
806
+ inside_root = os.path.commonpath(
807
+ [os.path.realpath(root), os.path.realpath(dr_path)]
808
+ ) == os.path.realpath(root)
809
+ except ValueError:
810
+ inside_root = False
811
+ if not inside_root:
812
+ raise RuntimeError("relative dr_sync_path escapes the current checkout")
813
+ if not os.path.isdir(dr_path) or not os.path.exists(src):
814
+ return
815
+ dst = os.path.join(dr_path, 'project-docs', relative_path)
816
+ try:
817
+ os.makedirs(os.path.dirname(os.path.abspath(dst)), exist_ok=True)
818
+ import shutil as _shutil
819
+ _shutil.copy2(src, dst)
820
+ except Exception:
821
+ pass
822
+
823
+
824
+ def _generated_doc_side_effects_allowed() -> bool:
825
+ """Return false for dispatcher jobs that must not emit project-wide docs."""
826
+ return "SYNLYNK_JOB_ID" not in os.environ
827
+
828
+
829
+ def _seed_verb_map(db_conn):
830
+ db_conn.executemany("""
831
+ INSERT OR IGNORE INTO harness_verb_map
832
+ (synlynk_verb, verb_category, harness_name, harness_command, supported, partial_notes)
833
+ VALUES (?,?,?,?,?,?)
834
+ """, _VERB_MAP_SEED)
835
+ db_conn.commit()
836
+
837
+
838
+ def _check_verb_support(verb: str, harness_name: str, db_conn) -> dict:
839
+ row = db_conn.execute(
840
+ "SELECT supported, partial_notes, harness_command FROM harness_verb_map WHERE synlynk_verb=? AND harness_name=?",
841
+ (verb, harness_name)
842
+ ).fetchone()
843
+ if not row:
844
+ return {"supported": "unknown", "block": False, "warn": False, "notes": None, "command": None}
845
+ supported, notes, cmd = row
846
+ return {
847
+ "supported": supported,
848
+ "block": supported == "none",
849
+ "warn": supported == "partial",
850
+ "notes": notes,
851
+ "command": cmd,
852
+ }
853
+
854
+
855
+ def _load_agent_config(name: str) -> dict:
856
+ """Load .harnesses/<name>.json or .agents/<name>.json. Raises FileNotFoundError with clear message."""
857
+ import json as _json
858
+ candidates = [
859
+ os.path.join(".harnesses", f"{name}.json"),
860
+ os.path.join("harnesses", f"{name}.json"),
861
+ os.path.join(".agents", f"{name}.json"),
862
+ os.path.join("agents", f"{name}.json"),
863
+ ]
864
+ path = next((candidate for candidate in candidates if os.path.exists(candidate)), candidates[0])
865
+ if not os.path.exists(path):
866
+ raise FileNotFoundError(f"No agent config found at {path}")
867
+ with open(path) as f:
868
+ return _json.load(f)
869
+
870
+
871
+ def _load_agent_profile(harness_name: str, agents_dir: str = ".agents") -> dict:
872
+ """Load a harness profile and normalize harness/model defaults."""
873
+ import json as _json
874
+
875
+ candidates = [
876
+ os.path.join(agents_dir, f"{harness_name}.json"),
877
+ os.path.join(".harnesses", f"{harness_name}.json"),
878
+ os.path.join("harnesses", f"{harness_name}.json"),
879
+ os.path.join(".agents", f"{harness_name}.json"),
880
+ os.path.join("agents", f"{harness_name}.json"),
881
+ ]
882
+ path = next((candidate for candidate in candidates if os.path.exists(candidate)), candidates[0])
883
+ if not os.path.exists(path):
884
+ return {"agent": harness_name, "harness": harness_name, "model": "unknown"}
885
+ try:
886
+ with open(path) as f:
887
+ profile = _json.load(f)
888
+ except (OSError, ValueError, TypeError):
889
+ return {"agent": harness_name, "harness": harness_name, "model": "unknown"}
890
+ profile.setdefault("harness", harness_name)
891
+ profile.setdefault("model", "unknown")
892
+ return profile
893
+
894
+
895
+ def _dispatch_flags_for_agent(agent: str) -> list:
896
+ """Return the executable dispatch flags for a harness baseline.
897
+
898
+ Supports both the legacy list form and the structured mapping form.
899
+ """
900
+ baselines = HARNESS_CAPABILITY_BASELINES.get(agent, {})
901
+ dispatch_flags = baselines.get("dispatch_flags", [])
902
+ if isinstance(dispatch_flags, dict):
903
+ ordered = []
904
+ for flag in dispatch_flags.get("required_flags", []) or []:
905
+ if flag not in ordered:
906
+ ordered.append(flag)
907
+ return ordered
908
+ return list(dispatch_flags or [])
909
+
910
+
911
+ def cmd_roles(fix: bool = False) -> None:
912
+ """Print current agent role table from config.
913
+
914
+ With --fix, regenerate role fences and repair SOP sections.
915
+ """
916
+ cfg = load_config()
917
+ roles = cfg.get("roles", {})
918
+ if isinstance(roles, dict):
919
+ visible_agents = [agent for agent in roles if agent]
920
+ elif isinstance(roles, (list, tuple)):
921
+ visible_agents = [agent for agent in roles if agent]
922
+ else:
923
+ visible_agents = []
924
+
925
+ print(f"\n {_BOLD}synlynk roles{_RESET}\n")
926
+ print(f" {'agent':<10} {'roles':<40} {'directive file':<12} fence")
927
+ print(f" {'─' * 10} {'─' * 40} {'─' * 12} {'─' * 10}")
928
+
929
+ for agent in visible_agents:
930
+ role_list = roles.get(agent, []) if isinstance(roles, dict) else []
931
+ fname = _directive_file_for_agent(agent)
932
+ roles_str = ", ".join(role_list) if isinstance(role_list, list) else str(role_list)
933
+ file_exists = os.path.exists(fname)
934
+ fence_present = False
935
+ if file_exists:
936
+ try:
937
+ with open(fname) as f:
938
+ fence_present = "<!-- synlynk:harness" in f.read()
939
+ except IOError:
940
+ pass
941
+ file_status = fname if file_exists else f"{fname} (missing)"
942
+ fence_status = f"{_GREEN}✓{_RESET}" if fence_present else f"{_YELLOW}missing{_RESET}"
943
+ print(f" {agent:<10} {roles_str:<40} {file_status:<12} {fence_status}")
944
+
945
+ if fix and file_exists and not fence_present:
946
+ roles_line = ", ".join(role_list) if isinstance(role_list, list) else str(role_list)
947
+ try:
948
+ _upsert_harness_fence(
949
+ fname,
950
+ harness_version="roles",
951
+ body=f"## Your Role\n{roles_line}\n",
952
+ )
953
+ print(f" {_GREEN}✓{_RESET} wrote role fence to {fname}")
954
+ except Exception as exc:
955
+ print(f" {_YELLOW}⚠{_RESET} could not write {fname}: {exc}")
956
+
957
+ if fix:
958
+ try:
959
+ _repair_sops_only(cfg=cfg, dry_run=False)
960
+ except Exception as exc:
961
+ print(f" {_YELLOW}⚠{_RESET} could not repair SOP sections: {exc}")
962
+
963
+ print()
964
+ if not fix:
965
+ missing = [
966
+ _directive_file_for_agent(a)
967
+ for a in visible_agents
968
+ if os.path.exists(_directive_file_for_agent(a))
969
+ and not _fence_exists(_directive_file_for_agent(a))
970
+ ]
971
+ if missing:
972
+ print(f" {_DIM}Run `synlynk roles --fix` to write missing role fences and repair SOP sections{_RESET}\n")
973
+ if not visible_agents:
974
+ print(f" {_DIM}No agents in roles to display{_RESET}\n")
975
+
976
+
977
+
978
+
979
+ _ENGG_DOMAIN_PATTERNS = [
980
+ ("data", [r"etl", r"pipeline/", r"schema\.sql", r"migrations/", r"dbt/"]),
981
+ ("ml", [r"ml/", r"models/", r"train\.", r"inference/", r"embeddings/"]),
982
+ ("security", [r"auth/", r"oauth", r"jwt", r"crypto", r"certs/"]),
983
+ ("devops", [r"\.github/", r"dockerfile", r"terraform", r"pulumi", r"k8s/", r"helm/"]),
984
+ ("frontend", [r"components/", r"pages/", r"\.tsx?", r"\.vue", r"\.svelte", r"styles/"]),
985
+ ("backend", [r"api/", r"routes/", r"handlers/", r"controllers/", r"services/"]),
986
+ ("testing", [r"tests/", r"test_", r"spec/", r"\.spec\.", r"fixtures/"]),
987
+ ("docs", [r"docs/", r"readme", r"\.md$", r"changelogs?"]),
988
+ ("architecture", [r"design/", r"specs/", r"adr/", r"diagrams/"]),
989
+ ]
990
+
991
+ def _infer_engg_domain(log_text: str) -> str:
992
+ """Infers engineering domain from file path patterns in job log output."""
993
+ lower = log_text.lower()
994
+ for domain, patterns in _ENGG_DOMAIN_PATTERNS:
995
+ if any(re.search(p, lower) for p in patterns):
996
+ return domain
997
+ return "unknown"
998
+
999
+
1000
+ JOBS_FILE = ".synlynk/jobs.json"
1001
+ LOGS_DIR = ".synlynk/logs"
1002
+ PROMPTS_DIR = ".synlynk/prompts"
1003
+
1004
+ _VERB_MAP_SEED = [
1005
+ # (synlynk_verb, category, agent, harness_command, supported, partial_notes)
1006
+ ("dispatch.task", "dispatch", "claude", "claude --print {task}", "full", None),
1007
+ ("dispatch.task", "dispatch", "agy", "agy -p {task}", "full", None),
1008
+ ("dispatch.task", "dispatch", "grok", "grok --single {task}", "full", None),
1009
+ ("dispatch.task", "dispatch", "codex", "codex exec - -s workspace-write", "full", None),
1010
+ ("dispatch.headless", "dispatch", "claude", "claude --print {task}", "full", None),
1011
+ ("dispatch.headless", "dispatch", "agy", "agy -p {task}", "partial", "May hang without PTY on some agy versions"),
1012
+ ("dispatch.headless", "dispatch", "grok", "grok --single {task}", "partial", "Network dep required"),
1013
+ ("dispatch.headless", "dispatch", "codex", "codex exec - -s workspace-write", "full", None),
1014
+ ("dispatch.resume", "dispatch", "claude", "claude --resume {session_id}", "full", None),
1015
+ ("dispatch.resume", "dispatch", "agy", None, "none", None),
1016
+ ("dispatch.resume", "dispatch", "grok", None, "none", None),
1017
+ ("dispatch.resume", "dispatch", "codex", None, "none", None),
1018
+ ("dispatch.approve", "dispatch", "claude", "claude --allowedTools {tools}", "full", None),
1019
+ ("dispatch.approve", "dispatch", "agy", None, "none", None),
1020
+ ("dispatch.approve", "dispatch", "grok", None, "none", None),
1021
+ ("dispatch.approve", "dispatch", "codex", None, "partial", "ask-for-approval=untrusted only"),
1022
+ ("dispatch.model", "dispatch", "claude", "--model {model}", "full", None),
1023
+ ("dispatch.model", "dispatch", "agy", "--model {model}", "full", None),
1024
+ ("dispatch.model", "dispatch", "grok", "--model {model}", "full", None),
1025
+ ("dispatch.model", "dispatch", "codex", "--model {model}", "full", None),
1026
+ ("dispatch.tools", "dispatch", "claude", "--allowedTools {tools}", "full", None),
1027
+ ("dispatch.tools", "dispatch", "agy", None, "partial", "No tool_list flag"),
1028
+ ("dispatch.tools", "dispatch", "grok", None, "none", None),
1029
+ ("dispatch.tools", "dispatch", "codex", None, "partial", "ask-for-approval only"),
1030
+ ("dispatch.context", "dispatch", "claude", "claude --print {task}", "full", None),
1031
+ ("dispatch.context", "dispatch", "agy", "agy -p {task}", "full", None),
1032
+ ("dispatch.context", "dispatch", "grok", "grok --prompt {task}", "partial", "No explicit context file flag"),
1033
+ ("dispatch.context", "dispatch", "codex", "codex exec - -s workspace-write", "full", None),
1034
+ ("jobs", "observability", "claude", None, "partial", "No native jobs subcommand"),
1035
+ ("jobs", "observability", "agy", None, "none", None),
1036
+ ("jobs", "observability", "grok", None, "none", None),
1037
+ ("jobs", "observability", "codex", None, "none", None),
1038
+ ("status", "observability", "claude", None, "partial", None),
1039
+ ("status", "observability", "agy", None, "none", None),
1040
+ ("status", "observability", "grok", None, "none", None),
1041
+ ("status", "observability", "codex", None, "none", None),
1042
+ ("telemetry", "observability", "claude", None, "none", None),
1043
+ ("telemetry", "observability", "agy", None, "none", None),
1044
+ ("telemetry", "observability", "grok", None, "none", None),
1045
+ ("telemetry", "observability", "codex", None, "none", None),
1046
+ ("costs", "observability", "claude", None, "partial", "Token count via /cost"),
1047
+ ("costs", "observability", "agy", None, "none", None),
1048
+ ("costs", "observability", "grok", None, "none", None),
1049
+ ("costs", "observability", "codex", None, "none", None),
1050
+ ("probe", "harness", "claude", "claude --version", "full", None),
1051
+ ("probe", "harness", "agy", "agy --version", "full", None),
1052
+ ("probe", "harness", "grok", "grok --version", "full", None),
1053
+ ("probe", "harness", "codex", "codex --version", "full", None),
1054
+ ("doctor", "harness", "claude", None, "full", None),
1055
+ ("doctor", "harness", "agy", None, "full", None),
1056
+ ("doctor", "harness", "grok", None, "full", None),
1057
+ ("doctor", "harness", "codex", None, "full", None),
1058
+ ("story", "pm", "claude", None, "none", None),
1059
+ ("story", "pm", "agy", None, "none", None),
1060
+ ("story", "pm", "grok", None, "none", None),
1061
+ ("story", "pm", "codex", None, "none", None),
1062
+ ("epic", "pm", "claude", None, "none", None),
1063
+ ("epic", "pm", "agy", None, "none", None),
1064
+ ("epic", "pm", "grok", None, "none", None),
1065
+ ("epic", "pm", "codex", None, "none", None),
1066
+ ("decide", "pm", "claude", None, "none", None),
1067
+ ("decide", "pm", "agy", None, "none", None),
1068
+ ("decide", "pm", "grok", None, "none", None),
1069
+ ("decide", "pm", "codex", None, "none", None),
1070
+ ("workspace", "workspace", "claude", None, "none", None),
1071
+ ("workspace", "workspace", "agy", None, "none", None),
1072
+ ("workspace", "workspace", "grok", None, "none", None),
1073
+ ("workspace", "workspace", "codex", None, "none", None),
1074
+ ("upgrade", "workspace", "claude", None, "partial", "Via /upgrade slash command"),
1075
+ ("upgrade", "workspace", "agy", None, "partial", "Via agy update"),
1076
+ ("upgrade", "workspace", "grok", None, "partial", None),
1077
+ ("upgrade", "workspace", "codex", None, "partial", None),
1078
+ ]
1079
+
1080
+ RELAY_EVENT_TYPES = frozenset({
1081
+ "story_updated",
1082
+ "job_dispatched",
1083
+ "job_completed",
1084
+ "alert_raised",
1085
+ "context_checkpoint",
1086
+ "table_changed",
1087
+ "broadcast",
1088
+ })
1089
+
1090
+
1091
+ def _build_relay_event(event_type: str, payload: dict) -> dict:
1092
+ """Constructs a relay event dict with required base fields."""
1093
+ if event_type not in RELAY_EVENT_TYPES:
1094
+ raise ValueError(
1095
+ f"unknown event type '{event_type}'. Valid: {sorted(RELAY_EVENT_TYPES)}"
1096
+ )
1097
+ import socket as _socket
1098
+
1099
+ event = {
1100
+ "type": event_type,
1101
+ "ts": int(time.time()),
1102
+ "origin_node": _socket.gethostname(),
1103
+ }
1104
+ event.update(payload)
1105
+ return event
1106
+
1107
+ # Default paths scanned for agent CLI config directories.
1108
+ # Overridable in .synlynk/config.json under "agent_discovery_paths".
1109
+ AGENT_DISCOVERY_DEFAULTS = {
1110
+ "claude": os.path.expanduser("~/.claude"),
1111
+ "codex": os.path.expanduser("~/.codex"),
1112
+ "agy": os.path.expanduser("~/.agy"),
1113
+ "grok": os.path.expanduser("~/.grok"),
1114
+ }
1115
+
1116
+ _AGENT_DIRECTIVE_FILES = {
1117
+ "claude": "CLAUDE.md",
1118
+ "agy": "GEMINI.md",
1119
+ "grok": "GROK.md",
1120
+ "codex": "AGENTS.md",
1121
+ }
1122
+
1123
+
1124
+ def _default_roles_for_agent(agent: str) -> list:
1125
+ """Return the default onboarded role list for an agent."""
1126
+ return {
1127
+ "claude": ["pm", "review", "deploy"],
1128
+ "agy": ["implement", "test", "css", "templates", "content"],
1129
+ "grok": ["implement", "test", "canvas", "js", "infra"],
1130
+ "codex": ["implement", "test", "refactor"],
1131
+ }.get(agent, [])
1132
+
1133
+
1134
+ def _default_roles_map() -> dict:
1135
+ """Return the default role mapping for supported agents."""
1136
+ return {name: _default_roles_for_agent(name) for name in _AGENT_DIRECTIVE_FILES}
1137
+
1138
+
1139
+ def _directive_file_for_agent(agent: str) -> str:
1140
+ return _AGENT_DIRECTIVE_FILES.get(agent, f"{agent}.md")
1141
+
1142
+
1143
+ def _write_json_atomic(path: str, payload: dict) -> None:
1144
+ """Write a JSON file atomically within the current workspace."""
1145
+ directory = os.path.dirname(path) or "."
1146
+ os.makedirs(directory, exist_ok=True)
1147
+ fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=".tmp.", suffix=".json")
1148
+ try:
1149
+ with os.fdopen(fd, "w") as f:
1150
+ json.dump(payload, f, indent=2)
1151
+ f.write("\n")
1152
+ f.flush()
1153
+ os.fsync(f.fileno())
1154
+ os.replace(tmp_path, path)
1155
+ finally:
1156
+ if os.path.exists(tmp_path):
1157
+ try:
1158
+ os.remove(tmp_path)
1159
+ except OSError:
1160
+ pass
1161
+
1162
+
1163
+ _WORKSPACE_KEYS = [
1164
+ "org", "owner", "repo", "project_id", "identity_slug", "project_docs_dir",
1165
+ "workspace_id", "agent_slots", "workgroup_agents", "agents", "team",
1166
+ "sync_endpoint", "dispatch", "local_auto_threshold", "local_fallback",
1167
+ "watch_interval_seconds", "auto_smoke_test", "auto_launch_after_wizard",
1168
+ "dispatch_mode", "fenced_commands", "nudges", "exec_timeout_minutes",
1169
+ "stall_timeout_minutes", "review_stall_timeout_minutes", "swarm_runners",
1170
+ "last_housekeeping_date", "features", "repo_id", "dr_sync_path", "mode",
1171
+ "termination_grace_seconds", "cleanup_verification_timeout_seconds",
1172
+ ]
1173
+ _BILLING_KEYS = ["budget", "harness_billing", "payment_models", "capability_sweep"]
1174
+ _POLICY_MIGRATED_KEYS = ["qa_gate_mode", "roles", "story_classification", "sentinel"]
1175
+
1176
+
1177
+ def _apply_workspace_defaults(config: dict) -> dict:
1178
+ """Fill in schema-v1 defaults for a workspace config dict, in place and returned."""
1179
+ defaults = {
1180
+ "schema_version": 1,
1181
+ "dispatch": {"stacking": "auto", "gate_suite_cmd": ""},
1182
+ "local_auto_threshold": 0.5,
1183
+ "local_fallback": "agy",
1184
+ "watch_interval_seconds": 30,
1185
+ "auto_smoke_test": False,
1186
+ "auto_launch_after_wizard": True,
1187
+ "dispatch_mode": "daily-grind",
1188
+ "fenced_commands": ["dispatch", "jobs", "exec", "schedule"],
1189
+ "nudges": {"enabled": True, "dismissed_ids": [], "last_shown": {}},
1190
+ "org": None,
1191
+ "owner": None,
1192
+ "repo": None,
1193
+ "project_id": None,
1194
+ "identity_slug": None,
1195
+ "project_docs_dir": "project-docs",
1196
+ "agent_slots": {"claude": "claude", "agy": "agy", "codex": "codex", "grok": "grok"},
1197
+ "workgroup_agents": [],
1198
+ "last_housekeeping_date": None,
1199
+ "team": None,
1200
+ "sync_endpoint": None,
1201
+ "exec_timeout_minutes": 30,
1202
+ "stall_timeout_minutes": 30,
1203
+ "swarm_runners": {"default": "local", "enabled": ["local"], "timeout_seconds": 900},
1204
+ "review_stall_timeout_minutes": 90,
1205
+ "termination_grace_seconds": 5,
1206
+ "cleanup_verification_timeout_seconds": 300,
1207
+ "agents": {},
1208
+ "features": {},
1209
+ "repo_id": None,
1210
+ "dr_sync_path": None,
1211
+ "mode": "solo",
1212
+ }
1213
+ if not isinstance(config, dict):
1214
+ config = {}
1215
+ for key, val in defaults.items():
1216
+ if key not in config:
1217
+ config[key] = val
1218
+ for key, val in defaults["dispatch"].items():
1219
+ if key not in config.get("dispatch", {}):
1220
+ config.setdefault("dispatch", {})[key] = val
1221
+ for key, val in defaults["nudges"].items():
1222
+ if key not in config.get("nudges", {}):
1223
+ config.setdefault("nudges", {})[key] = val
1224
+ return config
1225
+
1226
+
1227
+ def load_workspace() -> dict:
1228
+ """Load .synlynk/workspace.json with schema-v1 defaults."""
1229
+ config_file = ".synlynk/workspace.json"
1230
+ if not os.path.exists(config_file):
1231
+ return _apply_workspace_defaults({})
1232
+ try:
1233
+ with open(config_file) as f:
1234
+ config = json.load(f)
1235
+ if not isinstance(config, dict):
1236
+ return _apply_workspace_defaults({})
1237
+ return _apply_workspace_defaults(config)
1238
+ except (json.JSONDecodeError, IOError):
1239
+ return _apply_workspace_defaults({})
1240
+
1241
+
1242
+ def _apply_billing_defaults(config: dict) -> dict:
1243
+ """Fill in schema-v1 defaults for a billing config dict, in place and returned."""
1244
+ defaults = {
1245
+ "schema_version": 1,
1246
+ "budget": {"limit_usd": 10.0, "limit_requests": 100},
1247
+ "payment_models": {},
1248
+ "harness_billing": _default_harness_billing(),
1249
+ "capability_sweep": {"cost_cap_usd": 10.0},
1250
+ }
1251
+ if not isinstance(config, dict):
1252
+ config = {}
1253
+ has_harness_billing = "harness_billing" in config
1254
+ for key, val in defaults.items():
1255
+ if key not in config:
1256
+ config[key] = {} if key == "harness_billing" else val
1257
+ for key, val in defaults["budget"].items():
1258
+ if key not in config.get("budget", {}):
1259
+ config.setdefault("budget", {})[key] = val
1260
+ if not isinstance(config.get("harness_billing"), dict):
1261
+ config["harness_billing"] = _default_harness_billing()
1262
+ elif not config["harness_billing"] and has_harness_billing:
1263
+ config["harness_billing"] = _default_harness_billing()
1264
+ for billing in config["harness_billing"].values():
1265
+ if isinstance(billing, dict):
1266
+ billing.setdefault("payment_mode", "pay_as_you_go")
1267
+ billing.setdefault("monthly_base_fee_usd", billing.get("subscription_fee_usd", 0.0))
1268
+ billing.setdefault("projected_monthly_tokens", 10_000_000)
1269
+ billing.setdefault("allow_extra_usage", False)
1270
+ billing.setdefault("extra_usage_cap_usd", None)
1271
+ return config
1272
+
1273
+
1274
+ def load_billing() -> dict:
1275
+ """Load .synlynk/billing.json with schema-v1 defaults."""
1276
+ config_file = ".synlynk/billing.json"
1277
+ if not os.path.exists(config_file):
1278
+ return {
1279
+ "schema_version": 1,
1280
+ "budget": {"limit_usd": 10.0, "limit_requests": 100},
1281
+ "payment_models": {},
1282
+ "harness_billing": _default_harness_billing(),
1283
+ "capability_sweep": {"cost_cap_usd": 10.0},
1284
+ }
1285
+ try:
1286
+ with open(config_file) as f:
1287
+ config = json.load(f)
1288
+ if not isinstance(config, dict):
1289
+ return {
1290
+ "schema_version": 1,
1291
+ "budget": {"limit_usd": 10.0, "limit_requests": 100},
1292
+ "payment_models": {},
1293
+ "harness_billing": _default_harness_billing(),
1294
+ "capability_sweep": {"cost_cap_usd": 10.0},
1295
+ }
1296
+ return _apply_billing_defaults(config)
1297
+ except (json.JSONDecodeError, IOError):
1298
+ return {
1299
+ "schema_version": 1,
1300
+ "budget": {"limit_usd": 10.0, "limit_requests": 100},
1301
+ "payment_models": {},
1302
+ "harness_billing": _default_harness_billing(),
1303
+ "capability_sweep": {"cost_cap_usd": 10.0},
1304
+ }
1305
+
1306
+
1307
+ def _read_raw_policy() -> dict:
1308
+ """Read .synlynk/policy.json without defaults, or return {} if absent/corrupt."""
1309
+ try:
1310
+ with open(".synlynk/policy.json") as f:
1311
+ data = json.load(f)
1312
+ return data if isinstance(data, dict) else {}
1313
+ except (OSError, json.JSONDecodeError):
1314
+ return {}
1315
+
1316
+
1317
+ def _read_policy_migrated_fields(policy: dict | None = None) -> dict:
1318
+ """Read the legacy fields that are migrated into policy.json."""
1319
+ try:
1320
+ from synlynk.capability_roles import _load_capability_roles
1321
+ capability_roles = _load_capability_roles()
1322
+ except ImportError:
1323
+ capability_roles = None
1324
+ if policy is None:
1325
+ policy = _read_raw_policy()
1326
+ result = {
1327
+ "roles": capability_roles if capability_roles is not None else _default_roles_map(),
1328
+ "story_classification": {"method": "heuristic"},
1329
+ "qa_gate_mode": "block-only",
1330
+ "sentinel": {
1331
+ "dedup_window_seconds": 86400,
1332
+ "active_ttl_seconds": {"CRITICAL": 180 * 86400, "WARN": 180 * 86400, "INFO": 180 * 86400},
1333
+ },
1334
+ }
1335
+ if capability_roles is None and "roles" in policy:
1336
+ result["roles"] = policy["roles"]
1337
+ for key in ("story_classification", "qa_gate_mode", "sentinel"):
1338
+ if key in policy:
1339
+ result[key] = policy[key]
1340
+ return result
1341
+
1342
+
1343
+ def _split_legacy_config(legacy: dict, existing_policy: dict) -> tuple[dict, dict, dict]:
1344
+ """Partition a legacy config.json dict into workspace/billing/policy payloads.
1345
+
1346
+ Pure, no I/O. Workspace is the catch-all so unclaimed legacy keys are preserved.
1347
+ """
1348
+ claimed = set(_BILLING_KEYS) | set(_POLICY_MIGRATED_KEYS)
1349
+ workspace_payload = {"schema_version": 1}
1350
+ workspace_payload.update({k: v for k, v in legacy.items() if k not in claimed})
1351
+
1352
+ billing_payload = {"schema_version": 1}
1353
+ billing_payload.update({k: legacy[k] for k in _BILLING_KEYS if k in legacy})
1354
+
1355
+ policy_payload = dict(existing_policy)
1356
+ policy_payload.update({k: legacy[k] for k in _POLICY_MIGRATED_KEYS if k in legacy})
1357
+ policy_payload.setdefault("schema_version", 1)
1358
+
1359
+ return workspace_payload, billing_payload, policy_payload
1360
+
1361
+
1362
+ def migrate_legacy_config_if_needed() -> bool:
1363
+ """Explicitly split .synlynk/config.json into workspace, billing, and policy files once."""
1364
+ legacy_path = ".synlynk/config.json"
1365
+ workspace_path = ".synlynk/workspace.json"
1366
+ billing_path = ".synlynk/billing.json"
1367
+ policy_path = ".synlynk/policy.json"
1368
+
1369
+ if not os.path.exists(legacy_path):
1370
+ return False
1371
+ if os.path.exists(workspace_path) or os.path.exists(billing_path):
1372
+ return False
1373
+
1374
+ try:
1375
+ with open(legacy_path) as f:
1376
+ legacy = json.load(f)
1377
+ except (json.JSONDecodeError, IOError):
1378
+ return False
1379
+ if not isinstance(legacy, dict):
1380
+ return False
1381
+
1382
+ # A present but corrupt policy file aborts the whole migration. A missing
1383
+ # policy file is valid and uses an empty policy as its base.
1384
+ if os.path.exists(policy_path):
1385
+ try:
1386
+ with open(policy_path) as f:
1387
+ existing_policy = json.load(f)
1388
+ if not isinstance(existing_policy, dict):
1389
+ return False
1390
+ except (json.JSONDecodeError, IOError):
1391
+ return False
1392
+ else:
1393
+ existing_policy = {}
1394
+
1395
+ workspace_payload, billing_payload, policy_payload = _split_legacy_config(legacy, existing_policy)
1396
+
1397
+ _write_json_atomic(workspace_path, workspace_payload)
1398
+ _write_json_atomic(billing_path, billing_payload)
1399
+ _write_json_atomic(policy_path, policy_payload)
1400
+ os.replace(legacy_path, legacy_path + ".bak")
1401
+ return True
1402
+
1403
+ # ANSI helpers used by the wizard.
1404
+ _BOLD = "\033[1m"
1405
+ _GREEN = "\033[32m"
1406
+ _YELLOW = "\033[33m"
1407
+ _CYAN = "\033[36m"
1408
+ _DIM = "\033[2m"
1409
+ _RESET = "\033[0m"
1410
+ _RED = "\033[31m"
1411
+ _MAGENTA = "\033[35m"
1412
+
1413
+
1414
+ def _docs_dir() -> str:
1415
+ """Returns the configured project docs directory (defaults to 'project-docs').
1416
+
1417
+ Reads project_docs_dir from .synlynk/workspace.json (falls back to the
1418
+ legacy .synlynk/config.json for projects not yet migrated). Pass
1419
+ --docs-dir to synlynk init to set a custom location (e.g. '.' for repos
1420
+ that keep docs at the root).
1421
+ """
1422
+ workspace_file = ".synlynk/workspace.json"
1423
+ legacy_file = ".synlynk/config.json"
1424
+ config_file = workspace_file if os.path.exists(workspace_file) else legacy_file
1425
+ if os.path.exists(config_file):
1426
+ try:
1427
+ with open(config_file) as f:
1428
+ return json.load(f).get("project_docs_dir", "project-docs")
1429
+ except (json.JSONDecodeError, IOError):
1430
+ pass
1431
+ return "project-docs"
1432
+
1433
+
1434
+ _DEFAULT_HARNESS_BILLING = {
1435
+ "claude": {
1436
+ "payment_mode": "subscription",
1437
+ "monthly_base_fee_usd": 20.0,
1438
+ "projected_monthly_tokens": 10_000_000,
1439
+ "allow_extra_usage": False,
1440
+ },
1441
+ "codex": {
1442
+ "payment_mode": "subscription",
1443
+ "monthly_base_fee_usd": 20.0,
1444
+ "projected_monthly_tokens": 10_000_000,
1445
+ "allow_extra_usage": False,
1446
+ },
1447
+ "agy": {
1448
+ "payment_mode": "subscription",
1449
+ "monthly_base_fee_usd": 20.0,
1450
+ "projected_monthly_tokens": 10_000_000,
1451
+ "allow_extra_usage": False,
1452
+ },
1453
+ "grok": {
1454
+ "payment_mode": "subscription",
1455
+ "monthly_base_fee_usd": 30.0,
1456
+ "projected_monthly_tokens": 10_000_000,
1457
+ "allow_extra_usage": False,
1458
+ },
1459
+ }
1460
+
1461
+
1462
+ def _default_harness_billing() -> dict:
1463
+ """Return an independent copy of the standard quad-harness billing seed."""
1464
+ return {agent: dict(config) for agent, config in _DEFAULT_HARNESS_BILLING.items()}
1465
+
1466
+
1467
+ def load_config() -> dict:
1468
+ """Read the composed config without writing, renaming, or deleting files."""
1469
+ workspace_path = ".synlynk/workspace.json"
1470
+ billing_path = ".synlynk/billing.json"
1471
+ legacy_path = ".synlynk/config.json"
1472
+
1473
+ config = {"schema_version": 1}
1474
+
1475
+ if not os.path.exists(workspace_path) and not os.path.exists(billing_path) and os.path.exists(legacy_path):
1476
+ legacy = None
1477
+ try:
1478
+ with open(legacy_path) as f:
1479
+ legacy = json.load(f)
1480
+ except (json.JSONDecodeError, IOError):
1481
+ legacy = None
1482
+ if isinstance(legacy, dict):
1483
+ existing_policy = _read_raw_policy()
1484
+ workspace_payload, billing_payload, policy_payload = _split_legacy_config(legacy, existing_policy)
1485
+ config.update(_apply_workspace_defaults(workspace_payload))
1486
+ config.update(_apply_billing_defaults(billing_payload))
1487
+ config.update(_read_policy_migrated_fields(policy_payload))
1488
+ return config
1489
+
1490
+ config.update(load_workspace())
1491
+ config.update(load_billing())
1492
+ config.update(_read_policy_migrated_fields())
1493
+ return config
1494
+
1495
+
1496
+ def cmd_config_set(key: str, value) -> None:
1497
+ """Set a top-level config key, routed to its owning split config file."""
1498
+ migrate_legacy_config_if_needed()
1499
+ if key in _BILLING_KEYS:
1500
+ path = ".synlynk/billing.json"
1501
+ data = load_billing()
1502
+ elif key in _POLICY_MIGRATED_KEYS:
1503
+ path = ".synlynk/policy.json"
1504
+ data = _read_raw_policy()
1505
+ data.setdefault("schema_version", 1)
1506
+ else:
1507
+ path = ".synlynk/workspace.json"
1508
+ data = load_workspace()
1509
+ data[key] = value
1510
+ _write_json_atomic(path, data)
1511
+ print(f" ✓ {key} = {value!r} saved to {path}")
1512
+
1513
+
1514
+
1515
+
1516
+
1517
+
1518
+
1519
+
1520
+
1521
+
1522
+
1523
+
1524
+
1525
+
1526
+
1527
+
1528
+ def _check_upstream_divergence() -> None:
1529
+ """Warn if remote has commits the local branch hasn't pulled. Silent no-op otherwise."""
1530
+ try:
1531
+ local = subprocess.check_output(
1532
+ ["git", "rev-parse", "HEAD"], stderr=subprocess.DEVNULL
1533
+ ).decode().strip()
1534
+ upstream = subprocess.check_output(
1535
+ ["git", "rev-parse", "@{u}"], stderr=subprocess.DEVNULL
1536
+ ).decode().strip()
1537
+ except (subprocess.CalledProcessError, AttributeError):
1538
+ return # no upstream configured, detached HEAD, or not in git repo
1539
+ if local != upstream:
1540
+ try:
1541
+ behind = subprocess.check_output(
1542
+ ["git", "rev-list", "--count", "HEAD..@{u}"],
1543
+ stderr=subprocess.DEVNULL
1544
+ ).decode().strip()
1545
+ except (subprocess.CalledProcessError, AttributeError):
1546
+ behind = "?"
1547
+ print(f"⚠ Remote has {behind} commit(s) you haven't pulled. "
1548
+ f"Consider `git pull` before writing.\n Continuing anyway...")
1549
+
1550
+
1551
+ def _seed_devlog(username: str) -> None:
1552
+ """Creates project-docs/devlogs/<username>.md if absent. Idempotent."""
1553
+ devlog_dir = os.path.join(_docs_dir(), "devlogs")
1554
+ os.makedirs(devlog_dir, exist_ok=True)
1555
+ devlog_path = os.path.join(devlog_dir, f"{username}.md")
1556
+ if os.path.exists(devlog_path):
1557
+ return
1558
+ today = time.strftime("%Y-%m-%d")
1559
+ with open(devlog_path, "w") as f:
1560
+ f.write(f"# Devlog — @{username}\n\n")
1561
+ f.write(f"## {today} — Joined project\n")
1562
+ f.write("Joined via `synlynk join`.\n")
1563
+
1564
+
1565
+
1566
+
1567
+
1568
+
1569
+ def cmd_exit(dry_run: bool = True, remove_docs: bool = False) -> int:
1570
+ """Reverse synlynk onboarding: strip instruction sections, remove .synlynk/ and .agents/.
1571
+
1572
+ Writes SYNLYNK_HANDOFF.md summarising what was configured before removal.
1573
+ Dry-run by default — pass --confirm to execute.
1574
+ """
1575
+ manifest_data = _load_instruction_manifest()
1576
+ docs = None
1577
+ try:
1578
+ docs = _docs_dir()
1579
+ except Exception:
1580
+ pass
1581
+
1582
+ cfg = {}
1583
+ _cfg_path = ".synlynk/workspace.json" if os.path.exists(".synlynk/workspace.json") else ".synlynk/config.json"
1584
+ if os.path.exists(_cfg_path):
1585
+ try:
1586
+ cfg = json.load(open(_cfg_path))
1587
+ except Exception:
1588
+ pass
1589
+
1590
+ # Build action list
1591
+ strip_targets = []
1592
+ for fpath, info in manifest_data.items():
1593
+ style = _MARKER_STYLE_FOR_TOOL.get(info.get("tool", ""), "html")
1594
+ strip_targets.append((fpath, style, info.get("tool", "?")))
1595
+
1596
+ agent_profiles = []
1597
+ if os.path.isdir(".agents"):
1598
+ agent_profiles = [os.path.join(".agents", f) for f in os.listdir(".agents") if f.endswith(".json")]
1599
+
1600
+ synlynk_dir = ".synlynk"
1601
+ docs_dir_path = docs if (remove_docs and docs and os.path.isdir(docs)) else None
1602
+
1603
+ print(f"\n{_BOLD}synlynk exit{_RESET} {'(dry run — pass --confirm to execute)' if dry_run else '(executing)'}\n")
1604
+
1605
+ # Instruction files
1606
+ print(" Instruction files:")
1607
+ if strip_targets:
1608
+ for fpath, style, tool in strip_targets:
1609
+ exists = os.path.exists(fpath)
1610
+ tag = "remove" if style == "none" else "strip synlynk section"
1611
+ label = f"{_DIM}(not found){_RESET}" if not exists else ""
1612
+ print(f" {'→' if dry_run else '✓'} {fpath} [{tool}] — {tag} {label}")
1613
+ if not dry_run and exists:
1614
+ _strip_synlynk_section(fpath, style)
1615
+ else:
1616
+ print(f" {_DIM}no tracked instruction files{_RESET}")
1617
+
1618
+ # Agent profiles
1619
+ print(" Agent profiles:")
1620
+ if agent_profiles:
1621
+ for p in agent_profiles:
1622
+ print(f" {'→' if dry_run else '✓'} remove {p}")
1623
+ if not dry_run:
1624
+ os.remove(p)
1625
+ if not dry_run and os.path.isdir(".agents"):
1626
+ try:
1627
+ os.rmdir(".agents")
1628
+ except OSError:
1629
+ pass
1630
+ else:
1631
+ print(f" {_DIM}no .agents/ profiles found{_RESET}")
1632
+
1633
+ # .synlynk/ directory
1634
+ print(f" Config & state:")
1635
+ print(f" {'→' if dry_run else '✓'} remove {synlynk_dir}/")
1636
+ if not dry_run and os.path.isdir(synlynk_dir):
1637
+ import shutil as _shutil
1638
+ _shutil.rmtree(synlynk_dir)
1639
+
1640
+ # project-docs (optional)
1641
+ if docs_dir_path:
1642
+ print(f" Project docs:")
1643
+ print(f" {'→' if dry_run else '✓'} remove {docs_dir_path}/")
1644
+ if not dry_run:
1645
+ import shutil as _shutil
1646
+ _shutil.rmtree(docs_dir_path)
1647
+ elif remove_docs:
1648
+ print(f" Project docs: {_DIM}not found or already absent{_RESET}")
1649
+
1650
+ # Handoff doc
1651
+ handoff_path = "SYNLYNK_HANDOFF.md"
1652
+ handoff_lines = [
1653
+ f"# synlynk handoff — {time.strftime('%Y-%m-%d')}",
1654
+ "",
1655
+ "synlynk was removed from this repository. This file records what was configured.",
1656
+ "",
1657
+ "## Configuration",
1658
+ f"- Version: {cfg.get('synlynk_version', VERSION)}",
1659
+ f"- Mode: {cfg.get('mode', 'unknown')}",
1660
+ f"- Agents: {', '.join(cfg.get('agent_slots', {}).keys()) or 'unknown'}",
1661
+ f"- Org: {cfg.get('org', 'unknown')} / {cfg.get('repo', 'unknown')}",
1662
+ f"- Docs dir: {docs or 'unknown'}",
1663
+ "",
1664
+ "## Removed",
1665
+ ]
1666
+ for fpath, style, tool in strip_targets:
1667
+ handoff_lines.append(f"- `{fpath}` ({tool}) — synlynk section stripped")
1668
+ for p in agent_profiles:
1669
+ handoff_lines.append(f"- `{p}` — removed")
1670
+ handoff_lines += [
1671
+ f"- `.synlynk/` — removed",
1672
+ "",
1673
+ "## To re-initialize",
1674
+ "```",
1675
+ f"synlynk init --agents {','.join(cfg.get('agent_slots', {}).keys()) or 'claude,agy,codex,grok'}",
1676
+ "```",
1677
+ "",
1678
+ ]
1679
+ print(f" Handoff doc:")
1680
+ print(f" {'→' if dry_run else '✓'} write {handoff_path}")
1681
+ if not dry_run:
1682
+ with open(handoff_path, "w") as f:
1683
+ f.write("\n".join(handoff_lines))
1684
+
1685
+ print()
1686
+ if dry_run:
1687
+ print(f" Dry run complete. Run with {_CYAN}--confirm{_RESET} to apply changes.")
1688
+ else:
1689
+ print(f" {_GREEN}synlynk removed.{_RESET} See {handoff_path} for re-init instructions.")
1690
+ print()
1691
+ return 0
1692
+
1693
+
1694
+ def cmd_repair(dry_run: bool = True) -> int:
1695
+ """Exit synlynk and immediately re-initialize from the current configuration.
1696
+
1697
+ Reads config before exit so re-init uses the same agents/mode/org/repo/docs-dir.
1698
+ Dry-run by default — pass --confirm to execute.
1699
+ """
1700
+ cfg = {}
1701
+ _cfg_path = ".synlynk/workspace.json" if os.path.exists(".synlynk/workspace.json") else ".synlynk/config.json"
1702
+ if os.path.exists(_cfg_path):
1703
+ try:
1704
+ cfg = json.load(open(_cfg_path))
1705
+ except Exception:
1706
+ pass
1707
+
1708
+ agents_str = ",".join(cfg.get("agent_slots", {}).keys()) or "claude,agy,codex,grok"
1709
+ mode = cfg.get("mode", "solo")
1710
+ org = cfg.get("org") or None
1711
+ repo = cfg.get("repo") or None
1712
+ project_id = cfg.get("project_id") or None
1713
+ docs_dir_arg = cfg.get("docs_dir") or None
1714
+
1715
+ print(f"\n{_BOLD}synlynk repair{_RESET} {'(dry run — pass --confirm to execute)' if dry_run else '(executing)'}\n")
1716
+ print(f" Captured config: agents={agents_str} mode={mode} org={org or '—'} docs-dir={docs_dir_arg or 'project-docs'}")
1717
+ print()
1718
+
1719
+ print(" Step 1: exit")
1720
+ cmd_exit(dry_run=dry_run)
1721
+
1722
+ print(" Step 2: re-init")
1723
+ if dry_run:
1724
+ print(f" → synlynk init --agents {agents_str} --mode {mode}"
1725
+ + (f" --org {org}" if org else "")
1726
+ + (f" --repo {repo}" if repo else "")
1727
+ + (f" --project-id {project_id}" if project_id else "")
1728
+ + (f" --docs-dir {docs_dir_arg}" if docs_dir_arg else ""))
1729
+ print()
1730
+ print(f" Dry run complete. Run with {_CYAN}--confirm{_RESET} to apply.")
1731
+ else:
1732
+ init(
1733
+ agents=[a.strip() for a in agents_str.split(",") if a.strip()],
1734
+ mode=mode,
1735
+ org=org,
1736
+ repo=repo,
1737
+ project_id=project_id,
1738
+ docs_dir=docs_dir_arg,
1739
+ )
1740
+ try:
1741
+ from synlynk.doctor import cleanup_selftest_workspaces
1742
+
1743
+ removed = cleanup_selftest_workspaces()
1744
+ if removed:
1745
+ print(f" ✓ Cleaned up {removed} orphaned synlynk-selftest workspace(s)")
1746
+ except Exception:
1747
+ pass
1748
+ print(f"\n {_GREEN}Repair complete.{_RESET}")
1749
+ print()
1750
+ return 0
1751
+
1752
+
1753
+ def cmd_sync(dry_run: bool = True, repair_sops: bool = False) -> int:
1754
+ """Propagate updated synlynk artifacts to an existing repo without full re-init.
1755
+
1756
+ Updates: instruction file sections (CLAUDE.md, GEMINI.md, etc.), .agents/ profile
1757
+ defaults for any slots missing from .agents/. Does NOT touch project-docs/.
1758
+ Dry-run by default — pass --confirm to execute.
1759
+ """
1760
+ print(f"\n{_BOLD}synlynk sync{_RESET} {'(dry run — pass --confirm to execute)' if dry_run else '(executing)'}\n")
1761
+
1762
+ # --- instruction files ---
1763
+ manifest_data = _load_instruction_manifest()
1764
+ print(" Instruction files:")
1765
+ if not manifest_data:
1766
+ print(f" {_DIM}no tracked instruction files — run synlynk init first{_RESET}")
1767
+ else:
1768
+ _tool_content_builders = {
1769
+ "cursor": (_build_cursor_mdc, "none"),
1770
+ "copilot": (_build_copilot_instructions, "html"),
1771
+ "windsurf": (_build_windsurf_rules, "hash"),
1772
+ "universal": (lambda: _build_templates().get("AI_INSTRUCTIONS.md", ""), "html"),
1773
+ }
1774
+ updated_manifest = {}
1775
+ for fpath, info in manifest_data.items():
1776
+ tool = info.get("tool", "unknown")
1777
+ marker_style = _MARKER_STYLE_FOR_TOOL.get(tool, "html")
1778
+ if dry_run:
1779
+ print(f" → {fpath} [{tool}]")
1780
+ continue
1781
+ if tool in _tool_content_builders:
1782
+ builder, _ = _tool_content_builders[tool]
1783
+ content = builder()
1784
+ else:
1785
+ templates = _build_templates()
1786
+ content = templates.get(os.path.basename(fpath), "")
1787
+ _write_instruction_file(fpath, tool, content, marker_style)
1788
+ if os.path.exists(fpath):
1789
+ section = _extract_synlynk_section(open(fpath).read(), marker_style)
1790
+ if section:
1791
+ updated_manifest[fpath] = {"tool": tool, "sha": _compute_section_sha(section)}
1792
+ print(f" {_GREEN}✓{_RESET} {fpath} [{tool}]")
1793
+ if not dry_run and updated_manifest:
1794
+ _write_instruction_manifest(updated_manifest)
1795
+
1796
+ # --- agent profiles ---
1797
+ print(" Agent profiles (.agents/):")
1798
+ try:
1799
+ cfg = load_config()
1800
+ except Exception:
1801
+ cfg = {}
1802
+ slots = cfg.get("agent_slots", {})
1803
+ if not slots:
1804
+ print(f" {_DIM}no agent slots in config{_RESET}")
1805
+ else:
1806
+ os.makedirs(".agents", exist_ok=True) if not dry_run else None
1807
+ for name in slots:
1808
+ profile_path = os.path.join(".agents", f"{name}.json")
1809
+ if os.path.exists(profile_path):
1810
+ print(f" {_DIM}· {profile_path} already present — skipped{_RESET}")
1811
+ continue
1812
+ print(f" {'→' if dry_run else _GREEN + '✓' + _RESET} {profile_path} — create default profile")
1813
+ if not dry_run:
1814
+ profile = _load_agent_profile(name)
1815
+ os.makedirs(".agents", exist_ok=True)
1816
+ with open(profile_path, "w") as f:
1817
+ json.dump(profile, f, indent=2)
1818
+ f.write("\n")
1819
+
1820
+ print()
1821
+ if dry_run:
1822
+ print(f" Dry run complete. Run with {_CYAN}--confirm{_RESET} to apply.")
1823
+ else:
1824
+ print(f" {_GREEN}Sync complete.{_RESET}")
1825
+ print()
1826
+
1827
+ if repair_sops:
1828
+ _repair_sops_only(load_config(), dry_run=dry_run)
1829
+
1830
+ return 0
1831
+
1832
+
1833
+
1834
+
1835
+
1836
+
1837
+
1838
+
1839
+
1840
+
1841
+
1842
+ def _check_agent_functional(cli: str) -> Optional[str]:
1843
+ """Runs `<cli> --version` to confirm CLI is installed and executable.
1844
+
1845
+ Returns version string (stdout stripped) on success, None otherwise.
1846
+ """
1847
+ try:
1848
+ result = subprocess.run(
1849
+ [cli, "--version"],
1850
+ capture_output=True, text=True, timeout=5
1851
+ )
1852
+ if result.returncode == 0 and result.stdout.strip():
1853
+ return result.stdout.strip().splitlines()[0]
1854
+ return None
1855
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
1856
+ return None
1857
+
1858
+
1859
+ def discover_agents(config: dict = None) -> list:
1860
+ """Scans for installed harness CLIs and checks each is functional.
1861
+
1862
+ Returns list of dicts: {name, cli, version, functional, capabilities,
1863
+ roles, discovery_path}.
1864
+ Agents not found on disk are omitted. Agents found but failing --version
1865
+ are included with functional=False.
1866
+ """
1867
+ if config is None:
1868
+ config = load_config()
1869
+
1870
+ # Allow per-project overrides of discovery paths.
1871
+ discovery_paths = {**AGENT_DISCOVERY_DEFAULTS}
1872
+ discovery_paths.update(config.get("agent_discovery_paths", {}))
1873
+
1874
+ found = []
1875
+ for name, defaults in HARNESS_CAPABILITY_BASELINES.items():
1876
+ path = discovery_paths.get(name)
1877
+ if path and not os.path.exists(path):
1878
+ continue # config dir not present — skip entirely
1879
+ cli = defaults["cli"]
1880
+ version = _check_agent_functional(cli)
1881
+ found.append({
1882
+ "name": name,
1883
+ "cli": cli,
1884
+ "version": version,
1885
+ "functional": version is not None,
1886
+ "roles": defaults["roles"],
1887
+ "capabilities": defaults["strengths"],
1888
+ "non_interactive_flags": defaults["non_interactive_flags"],
1889
+ "discovery_path": path or "",
1890
+ })
1891
+ return found
1892
+
1893
+
1894
+ def probe_all_configured_harnesses(agents: list = None) -> dict:
1895
+ """Populate probe metadata for the harnesses selected for this workspace."""
1896
+ configured = list(agents) if agents is not None else []
1897
+ if agents is None:
1898
+ config = load_config()
1899
+ configured = list(config.get("workgroup_agents") or [])
1900
+ if agents is None and not configured:
1901
+ configured = [entry["name"] for entry in discover_agents() if entry.get("functional")]
1902
+ configured = list(dict.fromkeys(
1903
+ name for name in configured if name in HARNESS_CAPABILITY_BASELINES
1904
+ ))
1905
+ results = {}
1906
+ if not configured:
1907
+ return results
1908
+
1909
+ from synlynk.probe import _probe_agent
1910
+
1911
+ db_conn = _get_db()
1912
+ try:
1913
+ for harness_name in configured:
1914
+ try:
1915
+ results[harness_name] = _probe_agent(
1916
+ harness_name, db_conn, fast_path_ok=False, write_fence=False
1917
+ )
1918
+ except Exception as exc:
1919
+ results[harness_name] = {
1920
+ "status": "unavailable",
1921
+ "version": "unavailable",
1922
+ "error": str(exc),
1923
+ }
1924
+ db_conn.commit()
1925
+ finally:
1926
+ db_conn.close()
1927
+ return results
1928
+
1929
+
1930
+
1931
+
1932
+ _INDUSTRY_KEYWORDS = {
1933
+ "ott": ["ott", "over-the-top", "streaming service", "video platform"],
1934
+ "streaming": ["streaming", "live stream", "media delivery"],
1935
+ "fintech": ["fintech", "financial", "payment", "trading", "investment"],
1936
+ "banking": ["banking", "bank", "loan", "mortgage", "deposit"],
1937
+ "securities": ["securities", "stock", "equity", "portfolio", "brokerage"],
1938
+ "healthcare": ["healthcare", "medical", "patient", "clinical", "health"],
1939
+ "ecommerce": ["ecommerce", "e-commerce", "shop", "cart", "marketplace"],
1940
+ "edtech": ["edtech", "education", "learning", "course", "student"],
1941
+ "gaming": ["gaming", "game", "player", "leaderboard", "matchmaking"],
1942
+ }
1943
+
1944
+
1945
+
1946
+ _STACK_FINGERPRINTS = [
1947
+ ("pyproject.toml", "Python"),
1948
+ ("setup.py", "Python"),
1949
+ ("Cargo.toml", "Rust"),
1950
+ ("go.mod", "Go"),
1951
+ ("next.config.js", "Next.js"),
1952
+ ("next.config.ts", "Next.js"),
1953
+ ("next.config.mjs", "Next.js"),
1954
+ ("Pulumi.yaml", "Pulumi"),
1955
+ ("Pulumi.yml", "Pulumi"),
1956
+ ("Dockerfile", "Docker"),
1957
+ ("docker-compose.yml", "Docker"),
1958
+ ("docker-compose.yaml", "Docker"),
1959
+ ]
1960
+
1961
+ _STACK_EXT_MAP = {
1962
+ ".go": "Go",
1963
+ ".rs": "Rust",
1964
+ }
1965
+
1966
+
1967
+
1968
+
1969
+
1970
+
1971
+ _KNOWN_SKILL_PATTERNS = [
1972
+ "~/.claude/plugins/cache/superpowers-marketplace/superpowers/*/",
1973
+ "~/.config/gstack/plugins/*/",
1974
+ ]
1975
+ _SKILL_MANIFEST_NAMES = ("manifest.json", "package.json", "skill.json")
1976
+
1977
+
1978
+
1979
+
1980
+
1981
+
1982
+
1983
+
1984
+ _MONOREPO_MARKERS = ("packages", "apps", "services", "modules", "libs")
1985
+
1986
+
1987
+
1988
+
1989
+
1990
+
1991
+
1992
+
1993
+
1994
+
1995
+
1996
+
1997
+
1998
+
1999
+ STAGE_KEYS = ["stack", "source", "complexity", "tests", "git", "arch"]
2000
+
2001
+
2002
+
2003
+
2004
+
2005
+
2006
+
2007
+
2008
+
2009
+
2010
+
2011
+
2012
+
2013
+
2014
+
2015
+
2016
+
2017
+
2018
+
2019
+
2020
+
2021
+
2022
+
2023
+
2024
+
2025
+
2026
+ def cmd_agent_configure(agent_name: str) -> None:
2027
+ """Interactively write .agents/<agent_name>.json context-profile settings."""
2028
+ import json as _json
2029
+
2030
+ if agent_name not in HARNESS_CAPABILITY_BASELINES:
2031
+ print(f" Unknown agent '{agent_name}'. Known: {list(HARNESS_CAPABILITY_BASELINES)}")
2032
+ return
2033
+
2034
+ harnesses_dir = ".harnesses" if os.path.exists(".harnesses") or not os.path.exists(".agents") else ".agents"
2035
+ os.makedirs(harnesses_dir, exist_ok=True)
2036
+ path = os.path.join(harnesses_dir, f"{agent_name}.json")
2037
+
2038
+ existing = {}
2039
+ if os.path.exists(path):
2040
+ try:
2041
+ with open(path) as f:
2042
+ existing = _json.load(f)
2043
+ print(f" Existing profile at {path}:")
2044
+ for key, value in existing.items():
2045
+ print(f" {key}: {value}")
2046
+ except Exception:
2047
+ existing = {}
2048
+
2049
+ print(f"\n Configuring context profile for '{agent_name}' (press Enter to keep current)\n")
2050
+
2051
+ def _ask(default, desc):
2052
+ shown = "" if default is None else str(default)
2053
+ value = input(f" {desc} [{shown}]: ").strip()
2054
+ return value if value else default
2055
+
2056
+ context_mode = _ask(existing.get("context_mode", "task"),
2057
+ "context_mode (none / task / full)")
2058
+ max_bytes_raw = _ask(existing.get("context_max_bytes", None),
2059
+ "context_max_bytes (int, leave blank for no limit)")
2060
+
2061
+ profile = {
2062
+ "agent": agent_name,
2063
+ "harness": existing.get("harness", agent_name),
2064
+ "model": existing.get("model", "unknown"),
2065
+ "context_mode": context_mode,
2066
+ }
2067
+ if max_bytes_raw not in ("", None):
2068
+ try:
2069
+ profile["context_max_bytes"] = int(max_bytes_raw)
2070
+ except (TypeError, ValueError):
2071
+ pass
2072
+
2073
+ with open(path, "w") as f:
2074
+ _json.dump(profile, f, indent=2)
2075
+ f.write("\n")
2076
+ print(f"\n {_GREEN}✓{_RESET} Written {path}")
2077
+
2078
+
2079
+ def cmd_agent_add(agent_name: str) -> None:
2080
+ """Retrofit an on-PATH harness into the current project."""
2081
+ if agent_name not in HARNESS_CAPABILITY_BASELINES:
2082
+ print(f" Error: unknown agent '{agent_name}'. Known: {', '.join(sorted(HARNESS_CAPABILITY_BASELINES))}")
2083
+ return
2084
+
2085
+ cli_path = shutil.which(agent_name)
2086
+ if not cli_path:
2087
+ print(f" Error: agent binary '{agent_name}' is not on PATH.")
2088
+ return
2089
+
2090
+ config_path = ".synlynk/config.json"
2091
+ config = load_config()
2092
+ workgroup_agents = list(config.get("workgroup_agents") or [])
2093
+ directive_path = _directive_file_for_agent(agent_name)
2094
+ fence_present = _fence_exists(directive_path)
2095
+
2096
+ if agent_name in workgroup_agents and fence_present:
2097
+ print(f" {agent_name} is already fully onboarded — no changes made.")
2098
+ return
2099
+
2100
+ created_directive = False
2101
+ if not os.path.exists(directive_path):
2102
+ templates = _build_templates()
2103
+ template_body = templates.get(os.path.basename(directive_path), "")
2104
+ if not template_body:
2105
+ template_body = f"# synlynk {agent_name} instructions\n"
2106
+ with open(directive_path, "w") as f:
2107
+ f.write(template_body.rstrip("\n") + "\n")
2108
+ created_directive = True
2109
+
2110
+ probe_results = cmd_probe(agent_name, write_fence=False) or []
2111
+ probe_result = probe_results[0] if probe_results else {}
2112
+
2113
+ role_list = _default_roles_for_agent(agent_name)
2114
+ _upsert_harness_fence(
2115
+ directive_path,
2116
+ harness_version="roles",
2117
+ body=f"## Your Role\n{', '.join(role_list) if role_list else 'general'}\n",
2118
+ )
2119
+
2120
+ if agent_name not in workgroup_agents:
2121
+ workgroup_agents.append(agent_name)
2122
+
2123
+ agent_slots = dict(config.get("agent_slots") or {})
2124
+ agent_slots[agent_name] = agent_name
2125
+
2126
+ roles = dict(config.get("roles") or {})
2127
+ if not roles.get(agent_name):
2128
+ roles[agent_name] = role_list
2129
+
2130
+ config["workgroup_agents"] = workgroup_agents
2131
+ config["agent_slots"] = agent_slots
2132
+ config["roles"] = roles
2133
+ _write_json_atomic(config_path, config)
2134
+
2135
+ print(f" {_GREEN}✓{_RESET} added {agent_name} to workgroup_agents")
2136
+ print(f" {_GREEN}✓{_RESET} added {agent_name} to agent_slots")
2137
+ if created_directive:
2138
+ print(f" {_GREEN}✓{_RESET} created {directive_path} from template")
2139
+ else:
2140
+ print(f" {_GREEN}✓{_RESET} updated {directive_path}")
2141
+ print(f" {_GREEN}✓{_RESET} wrote role fence to {directive_path}")
2142
+ if probe_result:
2143
+ status = "skipped (up to date)" if probe_result.get("skipped") else probe_result.get("status", "unknown")
2144
+ print(f" {_GREEN}✓{_RESET} probe [{agent_name}] {probe_result.get('version', 'unknown')} → {status}")
2145
+ print(f" {_GREEN}✓{_RESET} onboarded {agent_name} from {cli_path}")
2146
+
2147
+
2148
+ cmd_harness_add = cmd_agent_add
2149
+ cmd_harness_configure = cmd_agent_configure
2150
+
2151
+
2152
+
2153
+ def _run_daily_housekeeping() -> None:
2154
+ """Run the once-per-day drift check triggered by exec flow."""
2155
+ workspace_path = ".synlynk/workspace.json"
2156
+ legacy_path = ".synlynk/config.json"
2157
+ if not os.path.exists(workspace_path) and not os.path.exists(legacy_path):
2158
+ return
2159
+
2160
+ config = load_config()
2161
+ today = time.strftime("%Y-%m-%d")
2162
+ if config.get("last_housekeeping_date") == today:
2163
+ return
2164
+
2165
+ workgroup_agents = [a for a in (config.get("workgroup_agents") or []) if a in HARNESS_CAPABILITY_BASELINES]
2166
+ known_on_path = {
2167
+ harness["name"]
2168
+ for harness in _detect_harnesses_on_path()
2169
+ if harness.get("name") in HARNESS_CAPABILITY_BASELINES
2170
+ }
2171
+ onboarded = set(workgroup_agents)
2172
+ new_agents = sorted(known_on_path - onboarded)
2173
+
2174
+ printed = False
2175
+ for agent_name in new_agents:
2176
+ print(f" New agent detected on PATH: {agent_name} — run `synlynk agent add {agent_name}` to onboard it")
2177
+ printed = True
2178
+
2179
+ db_conn = None
2180
+ try:
2181
+ if workgroup_agents:
2182
+ db_conn = _get_db()
2183
+ for agent_name in workgroup_agents:
2184
+ try:
2185
+ before = db_conn.execute(
2186
+ "SELECT installed_version, capability_hash FROM harness_records WHERE harness_name=?",
2187
+ (agent_name,),
2188
+ ).fetchone()
2189
+ result = _probe_agent(agent_name, db_conn, write_fence=False)
2190
+ after = db_conn.execute(
2191
+ "SELECT installed_version, capability_hash FROM harness_records WHERE harness_name=?",
2192
+ (agent_name,),
2193
+ ).fetchone()
2194
+ except Exception as exc:
2195
+ print(f" Housekeeping probe failed for {agent_name}: {exc}")
2196
+ printed = True
2197
+ continue
2198
+ if before != after:
2199
+ before_version = before[0] if before else "none"
2200
+ after_version = after[0] if after else result.get("version", "unknown")
2201
+ status = result.get("status", "unknown")
2202
+ print(f" Probe drift for {agent_name}: {before_version} → {after_version} ({status})")
2203
+ baseline = HARNESS_CAPABILITY_BASELINES.get(agent_name)
2204
+ if baseline is not None:
2205
+ baseline["last_probe_snapshot"] = {
2206
+ "installed_version": after_version,
2207
+ "capability_hash": after[1] if after else "",
2208
+ "status": status,
2209
+ }
2210
+ printed = True
2211
+ try:
2212
+ _repair_sops_only(harness_name=agent_name, dry_run=False)
2213
+ except Exception as exc:
2214
+ print(f" Housekeeping repair failed for {agent_name}: {exc}")
2215
+ printed = True
2216
+ finally:
2217
+ if db_conn is not None:
2218
+ db_conn.close()
2219
+
2220
+ config["last_housekeeping_date"] = today
2221
+ if os.path.exists(workspace_path):
2222
+ workspace_data = load_workspace()
2223
+ workspace_data["last_housekeeping_date"] = today
2224
+ _write_json_atomic(workspace_path, workspace_data)
2225
+ else:
2226
+ _write_json_atomic(legacy_path, config)
2227
+
2228
+ if not printed:
2229
+ return
2230
+
2231
+
2232
+ def cmd_configure_agent(
2233
+ name: str,
2234
+ flags: dict = None,
2235
+ envs: dict = None,
2236
+ network_deps: list = None,
2237
+ ) -> None:
2238
+ """Write per-project harness overrides to .agents/<name>.json."""
2239
+ flags = flags or {}
2240
+ envs = envs or {}
2241
+ network_deps = network_deps or []
2242
+
2243
+ os.makedirs(".agents", exist_ok=True)
2244
+ profile_path = os.path.join(".agents", f"{name}.json")
2245
+ profile: dict = {}
2246
+ if os.path.exists(profile_path):
2247
+ try:
2248
+ with open(profile_path) as f:
2249
+ profile = json.load(f)
2250
+ except (json.JSONDecodeError, OSError):
2251
+ profile = {}
2252
+
2253
+ overrides = profile.setdefault(
2254
+ "harness_overrides",
2255
+ {"dispatch_flags": {}, "env": {}, "network_deps": []},
2256
+ )
2257
+ overrides.setdefault("dispatch_flags", {})
2258
+ overrides.setdefault("env", {})
2259
+ overrides.setdefault("network_deps", [])
2260
+
2261
+ overrides["dispatch_flags"].update(flags)
2262
+ overrides["env"].update(envs)
2263
+ for dep in network_deps:
2264
+ if dep not in overrides["network_deps"]:
2265
+ overrides["network_deps"].append(dep)
2266
+
2267
+ with open(profile_path, "w") as f:
2268
+ json.dump(profile, f, indent=2)
2269
+ f.write("\n")
2270
+
2271
+ print(f" ✓ {name}: harness overrides written to {profile_path}")
2272
+ if flags:
2273
+ print(f" flags: {flags}")
2274
+ if envs:
2275
+ print(f" env: {envs}")
2276
+ if network_deps:
2277
+ print(f" deps: {network_deps}")
2278
+
2279
+
2280
+
2281
+
2282
+
2283
+
2284
+
2285
+
2286
+
2287
+
2288
+
2289
+
2290
+
2291
+
2292
+
2293
+
2294
+
2295
+
2296
+
2297
+
2298
+
2299
+
2300
+
2301
+
2302
+
2303
+
2304
+
2305
+ def cmd_shell(story_id: str = None) -> None:
2306
+ """Spawns an interactive subshell with synlynk context env vars injected.
2307
+
2308
+ The shell runs in the current directory (worktree-per-story lands in v0.5.0).
2309
+ On exit the calling process resumes normally.
2310
+ """
2311
+ shell = os.environ.get("SHELL", "/bin/bash")
2312
+ env = {**os.environ,
2313
+ "SYNLYNK_PROJECT_DIR": os.path.abspath("."),
2314
+ "SYNLYNK_STORY_ID": story_id or "",
2315
+ "SYNLYNK_CONTEXT": os.path.abspath(".synlynk/context.md")}
2316
+ label = f"story #{story_id}" if story_id else "synlynk"
2317
+ print(f"{_BOLD}Entering synlynk shell ({label}).{_RESET} "
2318
+ f"Type {_CYAN}exit{_RESET} to return.")
2319
+ subprocess.run([shell], env=env)
2320
+ print(f"{_DIM}Returned from synlynk shell.{_RESET}")
2321
+
2322
+
2323
+ def cmd_launch(agent: str, story_id: str = None) -> None:
2324
+ """Launches a harness CLI interactively in the current directory.
2325
+
2326
+ Pre-generates .synlynk/context-<agent>.md and starts the CLI so the
2327
+ agent reads it as initial context. Stdout/stderr are not captured —
2328
+ this is an interactive session. Telemetry is logged on exit.
2329
+ """
2330
+ if agent not in HARNESS_CAPABILITY_BASELINES:
2331
+ print(f"Unknown agent '{agent}'. Known: {list(HARNESS_CAPABILITY_BASELINES)}")
2332
+ return
2333
+
2334
+ cli = HARNESS_CAPABILITY_BASELINES[agent]["cli"]
2335
+
2336
+ try:
2337
+ generate_context(scope="full")
2338
+ except Exception:
2339
+ pass
2340
+ src = ".synlynk/context.md"
2341
+ dest = f".synlynk/context-{agent}.md"
2342
+ if os.path.exists(src):
2343
+ import shutil as _shutil
2344
+ _shutil.copy(src, dest)
2345
+
2346
+ label = f"story #{story_id}" if story_id else "interactive session"
2347
+ print(f"{_BOLD}Launching {agent} — {label}.{_RESET}")
2348
+ print(f" Context: {_CYAN}{dest}{_RESET}")
2349
+ print(f" Exit the agent to return to synlynk.\n")
2350
+
2351
+ start = time.time()
2352
+ result = subprocess.run([cli])
2353
+ duration = time.time() - start
2354
+
2355
+ log_telemetry_event({"type": "launch", "agent": agent,
2356
+ "story_id": story_id, "exit_code": result.returncode,
2357
+ "duration_s": round(duration, 1)})
2358
+ model_version = extract_model_version("", agent=agent)
2359
+ update_costs(
2360
+ cli,
2361
+ 0,
2362
+ 0,
2363
+ duration,
2364
+ cache_read_tokens=0,
2365
+ model_version=model_version,
2366
+ story_id=story_id,
2367
+ agent=agent,
2368
+ basis="none",
2369
+ )
2370
+ print(f"\n{_DIM}Returned from {agent}. Duration: {duration:.0f}s{_RESET}")
2371
+
2372
+
2373
+
2374
+
2375
+ def cmd_run_trio(task: str, story_id: str = None) -> None:
2376
+ """Dispatches all functional harnesses in parallel — one job per harness.
2377
+
2378
+ This is a parallel convenience wrapper, NOT the sequential Trio pipeline.
2379
+ Each agent gets the same task description and full context. For the
2380
+ sequential Architect→Build→Verify pipeline, see the Trio Protocol spec.
2381
+ """
2382
+ agents = [a for a in discover_agents() if a["functional"]]
2383
+ if not agents:
2384
+ print("No functional agents found. Synlynk is a neutral control plane that routes coding tasks across AI vendors and local models, then proves the result. Run `synlynk init` to configure an agent.")
2385
+ return
2386
+ if len(agents) < 3:
2387
+ print(f" {_YELLOW}Only {len(agents)} agent(s) available "
2388
+ f"(trio needs 3). Dispatching what's configured.{_RESET}")
2389
+
2390
+ print(f"{_BOLD}✨ Dispatching {len(agents)} agents in parallel{_RESET}")
2391
+ print(f" Task: {task}\n")
2392
+
2393
+ jobs = []
2394
+ for ag in agents:
2395
+ job = dispatch_agent(ag["name"], task, story_id=story_id)
2396
+ jobs.append(job)
2397
+ role = ag["roles"][0] if ag["roles"] else "worker"
2398
+ print(f" {_GREEN}▶{_RESET} [{job['id']}] {ag['name']:10} → {role} PID {job['pid']}")
2399
+
2400
+ print(f"\n {_DIM}All agents running in background.{_RESET}")
2401
+ print(f" Monitor with: {_CYAN}synlynk jobs{_RESET}")
2402
+ print(f" View output: {_CYAN}synlynk logs <job-id>{_RESET}")
2403
+
2404
+
2405
+ def set_state(state: str) -> None:
2406
+ """Writes synlynk state to .synlynk/state and updates terminal title."""
2407
+ icons = {"watching": "●", "active": "⚡", "stopped": "○"}
2408
+ state_file = ".synlynk/state"
2409
+ if not os.path.exists(".synlynk"):
2410
+ return
2411
+ with open(state_file, "w") as f:
2412
+ f.write(state)
2413
+ if sys.stdout.isatty():
2414
+ project = os.path.basename(os.getcwd())
2415
+ title = f"{icons.get(state, '○')} synlynk: {state} · {project}"
2416
+ sys.stdout.write(f"\033]0;{title}\007")
2417
+ sys.stdout.flush()
2418
+
2419
+ def detect_remote_owner_repo() -> tuple:
2420
+ """Returns (owner, repo) from git remote origin URL, or (None, None)."""
2421
+ try:
2422
+ result = subprocess.run(
2423
+ ["git", "remote", "get-url", "origin"],
2424
+ capture_output=True, text=True
2425
+ )
2426
+ if result.returncode != 0:
2427
+ return None, None
2428
+ url = result.stdout.strip().rstrip("/")
2429
+ if url.endswith(".git"):
2430
+ url = url[:-4]
2431
+ if "github.com/" in url:
2432
+ path = url.split("github.com/")[-1]
2433
+ elif "github.com:" in url:
2434
+ path = url.split("github.com:")[-1]
2435
+ else:
2436
+ return None, None
2437
+ parts = path.split("/")
2438
+ return (parts[0], parts[1]) if len(parts) >= 2 else (None, None)
2439
+ except Exception:
2440
+ return None, None
2441
+
2442
+
2443
+ def _update_config(updates: dict) -> None:
2444
+ """Merges updates into the owning split config file(s) (workspace.json / billing.json / policy.json)."""
2445
+ if not os.path.exists(".synlynk"):
2446
+ return
2447
+
2448
+ workspace_updates = {}
2449
+ billing_updates = {}
2450
+ policy_updates = {}
2451
+ for key, value in updates.items():
2452
+ if key in _BILLING_KEYS:
2453
+ billing_updates[key] = value
2454
+ elif key in _POLICY_MIGRATED_KEYS:
2455
+ policy_updates[key] = value
2456
+ else:
2457
+ workspace_updates[key] = value
2458
+
2459
+ if workspace_updates:
2460
+ workspace_data = load_workspace()
2461
+ workspace_data.update(workspace_updates)
2462
+ _write_json_atomic(".synlynk/workspace.json", workspace_data)
2463
+
2464
+ if billing_updates:
2465
+ billing_data = load_billing()
2466
+ billing_data.update(billing_updates)
2467
+ _write_json_atomic(".synlynk/billing.json", billing_data)
2468
+
2469
+ if policy_updates:
2470
+ policy_data = _read_raw_policy()
2471
+ policy_data.setdefault("schema_version", 1)
2472
+ policy_data.update(policy_updates)
2473
+ _write_json_atomic(".synlynk/policy.json", policy_data)
2474
+
2475
+
2476
+ # Task 3-5: Repo scanning, maturity detection, section signals, semantic matching, GH ID extraction
2477
+ _PROJECT_DOC_NAMES = {"roadmap.md", "todo.md", "memory.md", "costs.md", "devlog.md"}
2478
+ _AGENT_FILE_NAMES = {"CLAUDE.md", "GEMINI.md", "AGENTS.md", "GROK.md", "AI_INSTRUCTIONS.md"}
2479
+ _SCAN_SKIP_DIRS = {
2480
+ ".git", "node_modules", ".synlynk", "project-docs",
2481
+ "__pycache__", ".venv", "venv", "env", ".next", "dist", "build",
2482
+ "vendor", ".worktrees", "coverage", ".nyc_output", "target", "out", "tmp",
2483
+ }
2484
+
2485
+ _SOURCE_EXTENSIONS = {
2486
+ ".py": "python",
2487
+ ".js": "javascript", ".mjs": "javascript", ".cjs": "javascript",
2488
+ ".ts": "typescript", ".tsx": "typescript",
2489
+ ".go": "go",
2490
+ ".rs": "rust",
2491
+ ".rb": "ruby",
2492
+ ".java": "java",
2493
+ ".kt": "kotlin",
2494
+ ".sh": "shell",
2495
+ }
2496
+
2497
+ _SOURCE_ENTRY_POINTS = {
2498
+ "main.py", "app.py", "server.py", "index.js", "index.ts", "main.go",
2499
+ "lib.rs", "main.rs", "app.rb", "manage.py", "wsgi.py", "asgi.py", "__init__.py",
2500
+ }
2501
+
2502
+ _SYMBOL_PATTERNS = {
2503
+ "python": [
2504
+ (re.compile(r"^async def (\w+)"), "async_function"),
2505
+ (re.compile(r"^def (\w+)"), "function"),
2506
+ (re.compile(r"^class (\w+)"), "class"),
2507
+ (re.compile(r"^([A-Z_]{2,})\s*="), "constant"),
2508
+ ],
2509
+ "javascript": [
2510
+ (re.compile(r"^export (?:default )?(?:async )?function (\w+)"), "function"),
2511
+ (re.compile(r"^export (?:default )?class (\w+)"), "class"),
2512
+ (re.compile(r"^export const (\w+)"), "constant"),
2513
+ (re.compile(r"^function (\w+)"), "function"),
2514
+ (re.compile(r"^class (\w+)"), "class"),
2515
+ ],
2516
+ "typescript": [
2517
+ (re.compile(r"^export (?:default )?(?:async )?function (\w+)"), "function"),
2518
+ (re.compile(r"^export (?:default )?class (\w+)"), "class"),
2519
+ (re.compile(r"^export interface (\w+)"), "interface"),
2520
+ (re.compile(r"^export type (\w+)"), "type"),
2521
+ (re.compile(r"^export enum (\w+)"), "enum"),
2522
+ (re.compile(r"^export const (\w+)"), "constant"),
2523
+ (re.compile(r"^function (\w+)"), "function"),
2524
+ (re.compile(r"^class (\w+)"), "class"),
2525
+ ],
2526
+ "go": [
2527
+ (re.compile(r"^func (?:\(\w+ \*?\w+\) )?(\w+)"), "function"),
2528
+ (re.compile(r"^type (\w+) struct"), "struct"),
2529
+ (re.compile(r"^type (\w+) interface"), "interface"),
2530
+ ],
2531
+ "rust": [
2532
+ (re.compile(r"^pub fn (\w+)"), "function"),
2533
+ (re.compile(r"^pub struct (\w+)"), "struct"),
2534
+ (re.compile(r"^pub trait (\w+)"), "trait"),
2535
+ (re.compile(r"^pub enum (\w+)"), "enum"),
2536
+ (re.compile(r"^pub type (\w+)"), "type"),
2537
+ ],
2538
+ "ruby": [
2539
+ (re.compile(r"^def (\w+)"), "function"),
2540
+ (re.compile(r"^class (\w+)"), "class"),
2541
+ (re.compile(r"^module (\w+)"), "module"),
2542
+ ],
2543
+ "java": [
2544
+ (re.compile(r"(?:public|protected) (?:class|interface|enum) (\w+)"), "class"),
2545
+ (re.compile(r"(?:public|protected) \w+ (\w+)\s*\("), "function"),
2546
+ ],
2547
+ "kotlin": [
2548
+ (re.compile(r"^fun (\w+)"), "function"),
2549
+ (re.compile(r"^class (\w+)"), "class"),
2550
+ (re.compile(r"^object (\w+)"), "class"),
2551
+ (re.compile(r"^interface (\w+)"), "interface"),
2552
+ ],
2553
+ "shell": [
2554
+ (re.compile(r"^function (\w+)"), "function"),
2555
+ (re.compile(r"^(\w+)\(\)"), "function"),
2556
+ ],
2557
+ }
2558
+
2559
+ SECTION_SIGNALS: dict = {
2560
+ "## Live Issues SOP": [
2561
+ "live issue", "live-issue", "sev1", "sev2", "sev3", "rca", "[live-",
2562
+ ],
2563
+ "## Mid-Session Anti-Amnesia Protocol": [
2564
+ "25,000 tokens", "25k tokens", "compaction", "compaction imminent",
2565
+ "mid-session", "checkpoint every",
2566
+ ],
2567
+ "## Mandatory 4-Doc Discipline": [
2568
+ "roadmap.md", "devlog", "costs.md", "memory.md",
2569
+ "mandatory document", "four doc", "4-doc",
2570
+ ],
2571
+ "## GitHub Projects v2 Integration": [
2572
+ "updateProjectV2", "projectId", "PVT_", "PVTSSF_",
2573
+ "github projects", "programme board",
2574
+ ],
2575
+ "## Git Worktree-First Policy": [
2576
+ "git worktree", "worktree add", "never commit to main",
2577
+ "never commit to master",
2578
+ ],
2579
+ }
2580
+
2581
+
2582
+
2583
+
2584
+
2585
+
2586
+
2587
+
2588
+
2589
+
2590
+
2591
+
2592
+
2593
+
2594
+
2595
+
2596
+
2597
+
2598
+
2599
+
2600
+
2601
+
2602
+
2603
+
2604
+
2605
+
2606
+
2607
+
2608
+
2609
+
2610
+
2611
+
2612
+
2613
+
2614
+
2615
+
2616
+ _INSTRUCTIONS_MANIFEST = ".synlynk/instructions.json"
2617
+
2618
+ _INSTRUCTION_TARGETS = [
2619
+ # (path, tool, marker_style, detection_fn)
2620
+ # detection_fn: called in init() to decide whether to write the file.
2621
+ ("CLAUDE.md", "claude", "html", lambda: True),
2622
+ ("GEMINI.md", "agy", "html", lambda: True),
2623
+ ("AGENTS.md", "codex", "html", lambda: True),
2624
+ ("GROK.md", "grok", "html", lambda: True),
2625
+ (".cursor/rules/synlynk.mdc", "cursor", "none", lambda: os.path.isdir(".cursor")),
2626
+ (".github/copilot-instructions.md", "copilot", "html", lambda: os.path.isdir(".github")),
2627
+ (".windsurfrules", "windsurf", "hash", lambda: True),
2628
+ ("AI_INSTRUCTIONS.md", "universal", "html", lambda: True),
2629
+ ]
2630
+
2631
+
2632
+
2633
+
2634
+
2635
+ _MARKER_STYLE_FOR_TOOL = {
2636
+ "claude": "html",
2637
+ "agy": "html",
2638
+ "codex": "html",
2639
+ "grok": "html",
2640
+ "cursor": "none",
2641
+ "copilot": "html",
2642
+ "windsurf": "hash",
2643
+ "universal": "html",
2644
+ }
2645
+
2646
+
2647
+
2648
+
2649
+
2650
+
2651
+
2652
+
2653
+
2654
+
2655
+
2656
+
2657
+ def _is_interactive(cmd_args: list) -> bool:
2658
+ """Returns True if the command needs a real TTY (no stdout capture)."""
2659
+ NON_INTERACTIVE = ["--no-tty", "--output-format json", "--print",
2660
+ "--non-interactive", "-p "]
2661
+ cmd_str = " ".join(cmd_args)
2662
+ return not any(flag in cmd_str for flag in NON_INTERACTIVE)
2663
+
2664
+
2665
+ def _inject_grok_rules(cmd_args: list) -> list:
2666
+ """Adds Grok rules flags when invoking grok and the rule files exist."""
2667
+ if not cmd_args or cmd_args[0] != "grok":
2668
+ return list(cmd_args)
2669
+
2670
+ injected = [cmd_args[0]]
2671
+ if os.path.exists("GROK.md"):
2672
+ injected.extend(["--rules", "GROK.md"])
2673
+ if "-p" in cmd_args and os.path.exists(os.path.join(".synlynk", "context.md")):
2674
+ injected.extend(["--rules", os.path.join(".synlynk", "context.md")])
2675
+ injected.extend(cmd_args[1:])
2676
+ return injected
2677
+
2678
+
2679
+ def _tee_process(process, buffer: list) -> None:
2680
+ """Reads process stdout line-by-line, writes to terminal and appends to buffer."""
2681
+ for line in iter(process.stdout.readline, b''):
2682
+ sys.stdout.buffer.write(line)
2683
+ sys.stdout.buffer.flush()
2684
+ buffer.append(line.decode('utf-8', errors='replace'))
2685
+ process.stdout.close()
2686
+
2687
+
2688
+
2689
+
2690
+
2691
+
2692
+
2693
+
2694
+
2695
+
2696
+
2697
+
2698
+
2699
+
2700
+
2701
+
2702
+
2703
+
2704
+
2705
+
2706
+
2707
+ def _archive_old_devlog_entries(devlog_path: str, author: str) -> None:
2708
+ """Moves devlog entries older than 30 days to devlogs/archive/YYYY-MM.md."""
2709
+ import calendar
2710
+ if not os.path.exists(devlog_path):
2711
+ return
2712
+ cutoff = time.time() - (30 * 24 * 3600)
2713
+ pattern = re.compile(r'^## (\d{4}-\d{2}-\d{2})')
2714
+ sections = []
2715
+ current_lines, current_date = [], None
2716
+ with open(devlog_path) as f:
2717
+ for line in f:
2718
+ m = pattern.match(line)
2719
+ if m:
2720
+ if current_lines:
2721
+ sections.append((current_date, current_lines))
2722
+ current_date = m.group(1)
2723
+ current_lines = [line]
2724
+ else:
2725
+ current_lines.append(line)
2726
+ if current_lines:
2727
+ sections.append((current_date, current_lines))
2728
+
2729
+ keep, archive_by_month, archived_dates = [], {}, []
2730
+ for date_str, lines in sections:
2731
+ if date_str is None:
2732
+ keep.append((date_str, lines))
2733
+ continue
2734
+ try:
2735
+ ts = calendar.timegm(time.strptime(date_str, "%Y-%m-%d"))
2736
+ if ts < cutoff:
2737
+ month_key = date_str[:7]
2738
+ archive_by_month.setdefault(month_key, []).extend(lines)
2739
+ archived_dates.append(date_str)
2740
+ else:
2741
+ keep.append((date_str, lines))
2742
+ except ValueError:
2743
+ keep.append((date_str, lines))
2744
+
2745
+ if not archive_by_month:
2746
+ return
2747
+
2748
+ archive_dir = os.path.join(os.path.dirname(devlog_path), "archive")
2749
+ os.makedirs(archive_dir, exist_ok=True)
2750
+ for month_key, lines in archive_by_month.items():
2751
+ with open(os.path.join(archive_dir, f"{month_key}.md"), "a") as f:
2752
+ f.writelines(lines)
2753
+
2754
+ with open(devlog_path, "w") as f:
2755
+ for _, lines in keep:
2756
+ f.writelines(lines)
2757
+
2758
+ conn = _get_db()
2759
+ conn.executemany(
2760
+ "DELETE FROM devlog_entries WHERE author=? AND entry_date=?",
2761
+ [(author, date_str) for date_str in archived_dates],
2762
+ )
2763
+ conn.commit()
2764
+ conn.close()
2765
+
2766
+ def _resolve_member_id(username: str) -> str:
2767
+ """Looks up username in the member_aliases registry; falls back to username
2768
+ itself when unregistered (matches audit-docs' "unregistered" finding — an
2769
+ unregistered identity is reported, never silently reassigned)."""
2770
+ try:
2771
+ conn = _get_db()
2772
+ row = conn.execute(
2773
+ "SELECT member_id FROM member_aliases WHERE alias = ?", (username,)
2774
+ ).fetchone()
2775
+ conn.close()
2776
+ return row[0] if row else username
2777
+ except Exception:
2778
+ return username
2779
+
2780
+
2781
+ def checkpoint() -> None:
2782
+ """Archives done tasks, refreshes context, and emits a telemetry event."""
2783
+ set_state("active")
2784
+ _check_upstream_divergence()
2785
+ username = get_username()
2786
+ canonical_id = _resolve_member_id(username)
2787
+ if _is_migrated():
2788
+ devlog_path = os.path.join(_synlynk_project_docs_dir(), "devlogs", f"{canonical_id}.md")
2789
+ docs_dir = _synlynk_project_docs_dir()
2790
+ else:
2791
+ devlog_path = os.path.join(_docs_dir(), "devlogs", f"{canonical_id}.md")
2792
+ docs_dir = _docs_dir()
2793
+
2794
+ # Legacy projects may have a hand-written todo.md but no story rows yet.
2795
+ # Import it once before checkpointing so the database remains the source of truth.
2796
+ conn = _get_db()
2797
+ story_count = conn.execute("SELECT COUNT(*) FROM stories").fetchone()[0]
2798
+ if story_count == 0:
2799
+ _import_todo_to_stories(docs_dir, conn=conn)
2800
+ conn.commit()
2801
+
2802
+ resolved_rows = conn.execute(
2803
+ "SELECT story_id, title FROM stories "
2804
+ "WHERE archived_at IS NULL AND status IN ('done', 'superseded', 'absorbed') "
2805
+ "ORDER BY created_at ASC, id ASC"
2806
+ ).fetchall()
2807
+ completed = [{"id": story_id, "text": title or story_id} for story_id, title in resolved_rows]
2808
+ if resolved_rows:
2809
+ archived_at = time.strftime('%Y-%m-%d %H:%M:%S')
2810
+ conn.executemany(
2811
+ "UPDATE stories SET archived_at=? WHERE story_id=? AND archived_at IS NULL",
2812
+ [(archived_at, story_id) for story_id, _ in resolved_rows],
2813
+ )
2814
+ conn.commit()
2815
+ conn.close()
2816
+
2817
+ # Write resolved tasks through to the devlog (DB row + regenerated flat file)
2818
+ if completed:
2819
+ body_lines = ["### Resolved (checkpoint)"]
2820
+ for task in completed:
2821
+ body_lines.append(f"- {task['text']}")
2822
+ cmd_devlog_append(canonical_id, time.strftime('%Y-%m-%d'), "\n".join(body_lines) + "\n")
2823
+ if resolved_rows:
2824
+ _generate_todo_md()
2825
+
2826
+ _archive_old_devlog_entries(devlog_path, canonical_id)
2827
+ if os.path.exists(devlog_path):
2828
+ try:
2829
+ with open(devlog_path, "r", encoding="utf-8") as _df:
2830
+ _devlog_text = _df.read()
2831
+ _discovered = extract_from_devlog_content(_devlog_text, author=canonical_id)
2832
+ for _d in _discovered:
2833
+ stage_discovered_work(
2834
+ title=_d["title"],
2835
+ description=_d.get("description", ""),
2836
+ role=_d.get("role", "dev"),
2837
+ stage=_d.get("stage", "open"),
2838
+ source_type=_d.get("source_type", "devlog"),
2839
+ source_ref=_d.get("source_ref", f"devlog:{canonical_id}"),
2840
+ )
2841
+ except Exception:
2842
+ pass
2843
+ generate_context()
2844
+
2845
+ completed_ids = [t["id"] for t in completed if t["id"]]
2846
+ log_telemetry_event({
2847
+ "type": "checkpoint",
2848
+ "schema_version": 1,
2849
+ "timestamp": time.strftime('%Y-%m-%d %H:%M:%S'),
2850
+ "user": username,
2851
+ "completed_task_count": len(completed),
2852
+ "completed_task_ids": completed_ids,
2853
+ "devlog_entry_appended": bool(completed),
2854
+ })
2855
+
2856
+ total_usd, total_requests = parse_costs_md()
2857
+ config = load_config()
2858
+ limit_usd = config["budget"]["limit_usd"]
2859
+ pct = (total_usd / limit_usd * 100) if limit_usd else 0
2860
+
2861
+ daemon = WatchDaemon()
2862
+ set_state("watching" if daemon._is_running() else "stopped")
2863
+
2864
+ print(f"\n✓ checkpoint [@{username}] — {len(completed)} tasks archived, context refreshed")
2865
+ if completed:
2866
+ names = " · ".join(f'"{t["text"][:40]}"' for t in completed[:3])
2867
+ print(f" Archived: {names}")
2868
+ print(f" Budget: ${total_usd:.2f} / ${limit_usd:.2f} ({pct:.0f}%) · {total_requests} requests")
2869
+
2870
+ def _mark_roadmap_arc_shipped(version: str) -> None:
2871
+ """Flip a roadmap arc to shipped and sync its Milestone closed."""
2872
+ conn = _get_db()
2873
+ try:
2874
+ conn.execute(
2875
+ "UPDATE roadmap_arcs SET status='shipped' WHERE version=?", (version,)
2876
+ )
2877
+ conn.commit()
2878
+ finally:
2879
+ conn.close()
2880
+ _try_sync_arc_by_version(version)
2881
+
2882
+
2883
+ def cmd_release(
2884
+ dry_run: bool = False,
2885
+ version: Optional[str] = None,
2886
+ bump: bool = False,
2887
+ minor: bool = False,
2888
+ role: str = "pm",
2889
+ check_docs: bool = False,
2890
+ pr_mode: bool = False,
2891
+ waive: Optional[list] = None,
2892
+ ) -> None:
2893
+ """Cut a named release: bump version, prepend CHANGELOG.md, write blog stub, print checklist."""
2894
+ import datetime
2895
+ import re
2896
+ from synlynk.policy import check_authority
2897
+ from synlynk.release_readme import (
2898
+ format_readme_check_report,
2899
+ parse_waivers,
2900
+ validate_readme_for_release,
2901
+ )
2902
+
2903
+ try:
2904
+ waivers = parse_waivers(waive)
2905
+ except ValueError as exc:
2906
+ raise RuntimeError(str(exc)) from exc
2907
+
2908
+ if not check_docs:
2909
+ authority = check_authority("release_cut", role=role, repo_path=os.getcwd())
2910
+ if not authority.allowed:
2911
+ raise RuntimeError(f"Release refused: role {role!r} is not authorized to cut a release per policy.json.")
2912
+
2913
+ # Resolve project root:
2914
+ # First priority: check if a VERSION file exists in CWD. If so, root is CWD.
2915
+ if os.path.exists(os.path.join(os.getcwd(), "VERSION")):
2916
+ root = os.getcwd()
2917
+ else:
2918
+ root = _get_project_root()
2919
+
2920
+ version_path = os.path.join(root, "VERSION")
2921
+ changelog_path = os.path.join(root, "CHANGELOG.md")
2922
+ blog_dir = os.path.join(root, "docs", "blog")
2923
+
2924
+ # Step a: Determine next version
2925
+ current_version = ""
2926
+ if version:
2927
+ next_version = version
2928
+ else:
2929
+ # read current VERSION file
2930
+ if os.path.exists(version_path):
2931
+ with open(version_path, "r") as f:
2932
+ current_version = f.read().strip()
2933
+ else:
2934
+ current_version = "0.10.0"
2935
+
2936
+ # parse current_version
2937
+ parts = current_version.split(".")
2938
+ if len(parts) >= 3:
2939
+ try:
2940
+ major = int(parts[0])
2941
+ minor_ver = int(parts[1])
2942
+ patch = int(parts[2].split("-")[0].split("+")[0]) # strip any prerelease suffix
2943
+ except ValueError:
2944
+ major, minor_ver, patch = 0, 10, 0
2945
+ else:
2946
+ major, minor_ver, patch = 0, 10, 0
2947
+
2948
+ if minor:
2949
+ next_version = f"{major}.{minor_ver + 1}.0"
2950
+ else:
2951
+ next_version = f"{major}.{minor_ver}.{patch + 1}"
2952
+
2953
+ next_version = next_version.lstrip("v")
2954
+ print(f"Proposed version: v{next_version}")
2955
+
2956
+ if check_docs and version is None and not minor:
2957
+ expected_readme_version = current_version.lstrip("v")
2958
+ else:
2959
+ expected_readme_version = next_version
2960
+
2961
+ findings = validate_readme_for_release(
2962
+ root, expected_readme_version, waivers=waivers, pr_mode=pr_mode
2963
+ )
2964
+ if findings and not check_docs:
2965
+ print(f"⚡ [Marketing Ceremony] Auto-synchronizing README.md for v{expected_readme_version}...")
2966
+ from synlynk.release_readme import collect_pytest_test_count, sync_readme_for_release
2967
+ collected = collect_pytest_test_count(root)
2968
+ sync_readme_for_release(root, expected_readme_version, collected_count=collected)
2969
+ findings = validate_readme_for_release(
2970
+ root, expected_readme_version, waivers=waivers, pr_mode=pr_mode
2971
+ )
2972
+
2973
+ report = format_readme_check_report(
2974
+ findings, expected_readme_version, waivers=waivers
2975
+ )
2976
+ print(report)
2977
+ if check_docs:
2978
+ from synlynk.release_authority import (
2979
+ format_version_authority_report,
2980
+ validate_release_authority,
2981
+ )
2982
+
2983
+ authority_version, authority_findings = validate_release_authority(
2984
+ root, expected_version=expected_readme_version
2985
+ )
2986
+ print(format_version_authority_report(authority_version, authority_findings))
2987
+ findings.extend(authority_findings)
2988
+ if findings:
2989
+ if check_docs:
2990
+ sys.exit(1)
2991
+ raise RuntimeError(
2992
+ "README is not synchronized for this named release. "
2993
+ "Update README.md or re-run with --check-docs / --waive check=reason."
2994
+ )
2995
+ if check_docs:
2996
+ return
2997
+
2998
+ # Execute marketing release ceremony across collateral
2999
+ from synlynk.release_marketing import execute_release_ceremony
3000
+ print(f"🚀 [Marketing Ceremony] Executing release ceremony for v{next_version}...")
3001
+ ceremony_res = execute_release_ceremony(
3002
+ root=root,
3003
+ version=next_version,
3004
+ dry_run=dry_run,
3005
+ skip_build=False,
3006
+ )
3007
+ if not ceremony_res.ok:
3008
+ print(f"⚠️ [Marketing Ceremony] Notice: {', '.join(ceremony_res.errors)}")
3009
+ else:
3010
+ print(f"✅ [Marketing Ceremony] Collateral synchronized (README, {len(ceremony_res.docs_updated)} docs bundles, website)")
3011
+
3012
+ # Step b: Read merged stories since last git tag
3013
+ try:
3014
+ tag_cmd = "git describe --tags --abbrev=0 2>/dev/null || echo ''"
3015
+ last_tag = subprocess.check_output(tag_cmd, shell=True).decode("utf-8").strip()
3016
+ except Exception:
3017
+ last_tag = ""
3018
+
3019
+ if last_tag:
3020
+ git_cmd = f"git log {last_tag}..HEAD --oneline"
3021
+ else:
3022
+ git_cmd = "git log -n 20 --oneline"
3023
+
3024
+ try:
3025
+ log_output = subprocess.check_output(git_cmd, shell=True).decode("utf-8").strip()
3026
+ except Exception:
3027
+ log_output = ""
3028
+
3029
+ lines = log_output.splitlines() if log_output else []
3030
+ added = []
3031
+ fixed = []
3032
+ changed = []
3033
+
3034
+ for line in lines:
3035
+ line = line.strip()
3036
+ if not line:
3037
+ continue
3038
+ parts = line.split(" ", 1)
3039
+ if len(parts) < 2:
3040
+ continue
3041
+ msg = parts[1].strip()
3042
+
3043
+ match = re.match(r"^([a-zA-Z0-9_\-]+)(?:\(.*?\))?\s*:\s*(.*)", msg)
3044
+ if match:
3045
+ prefix = match.group(1).lower()
3046
+ else:
3047
+ prefix = ""
3048
+
3049
+ if prefix == "feat":
3050
+ added.append(msg)
3051
+ elif prefix == "fix":
3052
+ fixed.append(msg)
3053
+ else:
3054
+ changed.append(msg)
3055
+
3056
+ # Step c: Build CHANGELOG entry
3057
+ today = datetime.date.today().isoformat()
3058
+ changelog_parts = [f"## [v{next_version}] - {today}"]
3059
+ if added:
3060
+ changelog_parts.append("### Added")
3061
+ changelog_parts.extend(f"- {item}" for item in added)
3062
+ if fixed:
3063
+ changelog_parts.append("### Fixed")
3064
+ changelog_parts.extend(f"- {item}" for item in fixed)
3065
+ if changed:
3066
+ changelog_parts.append("### Changed")
3067
+ changelog_parts.extend(f"- {item}" for item in changed)
3068
+
3069
+ changelog_entry = "\n".join(changelog_parts) + "\n\n"
3070
+
3071
+ # Step d: Build blog post stub
3072
+ # Determine NN
3073
+ next_nn = 0
3074
+ if os.path.exists(blog_dir):
3075
+ existing_numbers = []
3076
+ for name in os.listdir(blog_dir):
3077
+ if name.endswith(".md"):
3078
+ match = re.match(r"^(\d+)-", name)
3079
+ if match:
3080
+ existing_numbers.append(int(match.group(1)))
3081
+ if existing_numbers:
3082
+ next_nn = max(existing_numbers) + 1
3083
+
3084
+ nn_str = f"{next_nn:02d}"
3085
+ blog_filename = f"{nn_str}-prTBD-v{next_version}.md"
3086
+ blog_path = os.path.join(blog_dir, blog_filename)
3087
+
3088
+ # Read template from docs/blog/README.md if it exists
3089
+ readme_path = os.path.join(blog_dir, "README.md")
3090
+ template_content = None
3091
+ if os.path.exists(readme_path):
3092
+ try:
3093
+ with open(readme_path, "r") as f:
3094
+ readme_text = f.read()
3095
+ h_idx = readme_text.find("## Per-PR Post Template")
3096
+ if h_idx != -1:
3097
+ sub = readme_text[h_idx:]
3098
+ m_idx = sub.find("```markdown")
3099
+ if m_idx != -1:
3100
+ start_idx = m_idx + len("```markdown")
3101
+ end_idx = sub.find("```", start_idx)
3102
+ if end_idx != -1:
3103
+ template_content = sub[start_idx:end_idx].strip()
3104
+ except Exception:
3105
+ pass
3106
+
3107
+ if not template_content:
3108
+ template_content = """---
3109
+ title: "PR #N — <theme>"
3110
+ date: YYYY-MM-DD
3111
+ series: "Building the OS for Multi-Agent Development"
3112
+ post: N
3113
+ pr: "#N"
3114
+ merged: YYYY-MM-DD (or status: open)
3115
+ ---
3116
+
3117
+ ## The Broader Goal at the End of the Previous PR
3118
+ [What was the stated/understood goal before this work started?]
3119
+
3120
+ ## Strategic Shifts in This PR (if any)
3121
+ [What changed in the broader strategy? What moved the goalpost and why?]
3122
+
3123
+ ## What This PR Shipped
3124
+ [Deep technical description: commands, key implementation decisions, data structures, test approach]
3125
+
3126
+ ## Brainstorm Visuals Used
3127
+ [Links to any HTML brainstorm files in docs/brainstorm/ that informed decisions in this PR]
3128
+
3129
+ ## What This Achieved on the Path to Autonomy
3130
+ [Specific ways this PR advances the eventual goal of autonomous multi-agent dispatch]
3131
+
3132
+ ## Strategic Note: The Goal at the End of This PR
3133
+ [The new goalpost, as understood after this PR's decisions]"""
3134
+
3135
+ stub_content = template_content
3136
+ stub_content = stub_content.replace('title: "PR #N — <theme>"', f'title: "PR #TBD — v{next_version} Release"')
3137
+ stub_content = stub_content.replace('date: YYYY-MM-DD', f'date: {today}')
3138
+ stub_content = stub_content.replace('post: N', f'post: {next_nn}')
3139
+ stub_content = stub_content.replace('pr: "#N"', 'pr: "#TBD"')
3140
+ stub_content = stub_content.replace('merged: YYYY-MM-DD (or status: open)', 'status: open')
3141
+ stub_content = stub_content.replace('merged: status' + ': open', 'status: open')
3142
+
3143
+ if dry_run:
3144
+ # Step f: In dry-run mode, print everything but write nothing
3145
+ print("\n--- Proposed CHANGELOG Entry ---")
3146
+ print(changelog_entry.strip())
3147
+ print("\n--- Proposed Blog Post Stub ---")
3148
+ print(f"Path: docs/blog/{blog_filename}")
3149
+ print(stub_content.strip())
3150
+ print("\n--------------------------------")
3151
+ else:
3152
+ # Write the single canonical VERSION source.
3153
+ with open(version_path, "w") as f:
3154
+ f.write(next_version + "\n")
3155
+
3156
+ # Prepend to CHANGELOG.md (create if missing)
3157
+ if os.path.exists(changelog_path):
3158
+ with open(changelog_path, "r") as f:
3159
+ old_content = f.read()
3160
+ match = re.search(r"##\s*\[v?\d+\.\d+\.\d+\]", old_content)
3161
+ if match:
3162
+ idx = match.start()
3163
+ new_changelog = old_content[:idx] + changelog_entry + "---\n\n" + old_content[idx:]
3164
+ else:
3165
+ idx = old_content.find("## ")
3166
+ if idx != -1:
3167
+ new_changelog = old_content[:idx] + changelog_entry + "---\n\n" + old_content[idx:]
3168
+ else:
3169
+ new_changelog = old_content + "\n\n" + changelog_entry
3170
+ else:
3171
+ new_changelog = "# Changelog\n\nAll notable changes to synlynk are documented here.\n\n" + changelog_entry
3172
+
3173
+ with open(changelog_path, "w") as f:
3174
+ f.write(new_changelog)
3175
+
3176
+ # Write blog post stub
3177
+ os.makedirs(os.path.dirname(blog_path), exist_ok=True)
3178
+ with open(blog_path, "w") as f:
3179
+ f.write(stub_content + "\n")
3180
+
3181
+ # Step e: Print named release checklist
3182
+ print(f"synlynk release checklist for v{next_version}")
3183
+ print("[x] VERSION bumped")
3184
+ print("[x] CHANGELOG entry written")
3185
+ print(f"[x] Blog post stub: docs/blog/{blog_filename}")
3186
+ print("[x] README synchronized (version, collected test-count not pass/fail, hero, install, links, commands)")
3187
+ print("[x] Marketing Release Ceremony completed:")
3188
+ print(" - [x] GitHub README synchronized")
3189
+ print(f" - [{'x' if ceremony_res.website_updated else ' '}] Synlynk.com website (website/) updated for release")
3190
+ print(f" - [{'x' if ceremony_res.docs_updated else ' '}] Synlynk Docs bundles synchronized: Quick Start, Official Reference, Command Ref (HTML & PDF)")
3191
+ print(f"[ ] git tag v{next_version} && git push --tags")
3192
+ print(f"[ ] gh release create v{next_version}")
3193
+ if dry_run:
3194
+ print(f" [ ] Roadmap row would be marked shipped and Milestone closed — {next_version}")
3195
+ else:
3196
+ _mark_roadmap_arc_shipped(next_version)
3197
+ print(f" [x] Roadmap row marked shipped and Milestone closed — {next_version}")
3198
+
3199
+
3200
+ def cmd_marketing_ceremony(
3201
+ version: Optional[str] = None,
3202
+ dry_run: bool = False,
3203
+ skip_build: bool = False,
3204
+ ) -> None:
3205
+ """Execute the Marketing Release Ceremony across README, docs bundles, and website."""
3206
+ from synlynk.release_marketing import execute_release_ceremony
3207
+
3208
+ root = os.getcwd()
3209
+ if not os.path.exists(os.path.join(root, "VERSION")):
3210
+ try:
3211
+ root = _get_project_root()
3212
+ except Exception:
3213
+ root = os.getcwd()
3214
+
3215
+ if not version:
3216
+ version_file = os.path.join(root, "VERSION")
3217
+ if os.path.isfile(version_file):
3218
+ with open(version_file, "r", encoding="utf-8") as f:
3219
+ version = f.read().strip()
3220
+ else:
3221
+ version = "0.20.0"
3222
+
3223
+ print(f"🚀 Executing Marketing Release Ceremony for v{version.lstrip('v')} (dry_run={dry_run})...")
3224
+ res = execute_release_ceremony(
3225
+ root=root,
3226
+ version=version,
3227
+ dry_run=dry_run,
3228
+ skip_build=skip_build,
3229
+ )
3230
+ if not res.ok:
3231
+ print(f"❌ Marketing Release Ceremony failed: {', '.join(res.errors)}")
3232
+ sys.exit(1)
3233
+
3234
+ prefix = "[DRY-RUN] " if res.dry_run else ""
3235
+ print(f"✅ {prefix}Marketing Release Ceremony completed successfully for v{res.version}.")
3236
+ print(f" - README updated: {res.readme_updated}")
3237
+ print(f" - Docs bundles updated: {len(res.docs_updated)} files ({', '.join(res.docs_updated) if res.docs_updated else 'none'})")
3238
+ print(f" - Website metadata updated: {res.website_updated}")
3239
+ print(f" - Website build ok: {res.website_build_ok}")
3240
+
3241
+
3242
+ def cmd_marketing_sync_pr(pr_number: Union[int, str], dry_run: bool = False) -> None:
3243
+ """Executes the limited-treatment marketing sync for a merged pull request."""
3244
+ from synlynk.marketing import cmd_marketing_sync_pr as _impl
3245
+ return _impl(pr_number=pr_number, dry_run=dry_run)
3246
+
3247
+
3248
+ def cmd_status(json_output: bool = False, platform: bool = False) -> None:
3249
+ """Displays project state dashboard. Exits 1 if sentinel active or budget exceeded."""
3250
+ if platform:
3251
+ has_alert = _print_platform_health()
3252
+ sys.exit(1 if has_alert else 0)
3253
+
3254
+ username = get_username()
3255
+ mode = get_mode()
3256
+
3257
+ # Active tasks
3258
+ active_tasks = []
3259
+ todo_path = "project-docs/todo.md"
3260
+ if os.path.exists(todo_path):
3261
+ with open(todo_path) as f:
3262
+ for line in f:
3263
+ if "- [ ]" in line:
3264
+ id_m = re.search(r'<!--\s*id:\s*(\d+)\s*-->', line)
3265
+ text = re.sub(r'-\s*\[ \]\s*', '', line).strip()
3266
+ text = re.sub(r'<!--.*?-->', '', text).strip()
3267
+ active_tasks.append({"id": id_m.group(1) if id_m else None, "text": text})
3268
+
3269
+ telemetry_events = _load_telemetry_events()
3270
+
3271
+ # Last checkpoint from telemetry
3272
+ last_checkpoint = None
3273
+ for e in reversed(telemetry_events):
3274
+ if e.get("type") == "checkpoint":
3275
+ last_checkpoint = e
3276
+ break
3277
+
3278
+ # Sentinel alerts
3279
+ sentinel_file = ".synlynk/sentinel.md"
3280
+ sentinel_alerts = _read_sentinel_alerts(sentinel_path=sentinel_file)
3281
+
3282
+ # Budget
3283
+ total_usd, total_requests = parse_costs_md()
3284
+ config = load_config()
3285
+ limit_usd = config["budget"]["limit_usd"]
3286
+ limit_reqs = config["budget"]["limit_requests"]
3287
+
3288
+ # Watcher
3289
+ daemon = WatchDaemon()
3290
+ watcher_running = daemon._is_running()
3291
+ last_trigger_file = None
3292
+ for e in reversed(telemetry_events):
3293
+ if e.get("type") == "watch_trigger":
3294
+ last_trigger_file = e.get("changed_file")
3295
+ break
3296
+
3297
+ # Teammates (team mode)
3298
+ teammates = []
3299
+ if mode == "team":
3300
+ devlogs_dir = "project-docs/devlogs"
3301
+ if os.path.exists(devlogs_dir):
3302
+ for fname in sorted(os.listdir(devlogs_dir)):
3303
+ if fname.endswith(".md") and fname not in (f"{username}.md", "README.md"):
3304
+ fpath = os.path.join(devlogs_dir, fname)
3305
+ teammates.append({
3306
+ "user": fname[:-3],
3307
+ "last_active": _get_last_devlog_date(fpath),
3308
+ })
3309
+
3310
+ has_alert = bool(sentinel_alerts) or total_usd >= limit_usd or total_requests >= limit_reqs
3311
+
3312
+ if json_output:
3313
+ data = {
3314
+ "schema_version": 1,
3315
+ "timestamp": time.strftime('%Y-%m-%dT%H:%M:%S'),
3316
+ "user": username,
3317
+ "mode": mode,
3318
+ "active_tasks": active_tasks,
3319
+ "last_checkpoint": last_checkpoint,
3320
+ "sentinel": {"alerts": sentinel_alerts},
3321
+ "budget": {
3322
+ "used_usd": round(total_usd, 4),
3323
+ "limit_usd": limit_usd,
3324
+ "requests": total_requests,
3325
+ "limit_requests": limit_reqs,
3326
+ },
3327
+ "watcher": {"running": watcher_running, "last_trigger_file": last_trigger_file},
3328
+ "teammates": teammates,
3329
+ }
3330
+ print(json.dumps(data, indent=2))
3331
+ sys.exit(1 if has_alert else 0)
3332
+
3333
+ # Human output
3334
+ sep = "─" * 45
3335
+ print(sep)
3336
+ print(f" synlynk status · @{username} · {mode} mode")
3337
+ print(sep)
3338
+ print(f" ACTIVE TASKS ({len(active_tasks)})")
3339
+ for t in active_tasks:
3340
+ tid = f"#{t['id']}" if t['id'] else ""
3341
+ print(f" [ ] {t['text']:<40} {tid}")
3342
+ print()
3343
+ print(" LAST CHECKPOINT")
3344
+ if last_checkpoint:
3345
+ print(f" @{last_checkpoint.get('user')} · {last_checkpoint.get('timestamp')} · "
3346
+ f"{last_checkpoint.get('completed_task_count', 0)} tasks archived")
3347
+ else:
3348
+ print(" No checkpoints yet")
3349
+ print()
3350
+ print(" SENTINEL")
3351
+ if sentinel_alerts:
3352
+ for alert in sentinel_alerts:
3353
+ print(f" ⚠ {alert}")
3354
+ else:
3355
+ print(" ✓ No alerts")
3356
+ print()
3357
+ pct = (total_usd / limit_usd * 100) if limit_usd else 0
3358
+ print(" BUDGET")
3359
+ print(f" ${total_usd:.2f} / ${limit_usd:.2f} ({pct:.0f}%) · {total_requests} / {limit_reqs} requests")
3360
+ avg_cost, remaining_execs = _compute_burn_rate()
3361
+ if avg_cost > 0:
3362
+ runway = f"~{remaining_execs:,} execs remaining" if remaining_execs is not None else "N/A"
3363
+ print(f" Burn: ${avg_cost:.4f}/exec avg | {runway} at current pace")
3364
+ print()
3365
+ icon = "●" if watcher_running else "○"
3366
+ state = "Running" if watcher_running else "Stopped"
3367
+ trigger = f" · last trigger {last_trigger_file}" if last_trigger_file else ""
3368
+ print(f" WATCHER\n {icon} {state}{trigger}")
3369
+ sentinel_path = os.path.join(os.getcwd(), ".synlynk", "sentinel.md")
3370
+ check_daemon_health(sentinel_path=sentinel_path)
3371
+ check_stall(sentinel_path=sentinel_path)
3372
+ if mode == "team" and teammates:
3373
+ print()
3374
+ print(" TEAMMATES")
3375
+ for tm in teammates:
3376
+ print(f" @{tm['user']:<12} · last active {tm['last_active']}")
3377
+ print()
3378
+ print(f" {_CYAN}CAPABILITY LEDGER{_RESET}")
3379
+ try:
3380
+ _cl_conn = _get_db()
3381
+ _cl_rows = _cl_conn.execute(
3382
+ "SELECT agent, model_version, engg_domain, phase, weighted_score, sample_count "
3383
+ "FROM capability_scores ORDER BY weighted_score DESC LIMIT 3"
3384
+ ).fetchall()
3385
+ _cl_conn.close()
3386
+ except Exception:
3387
+ _cl_rows = []
3388
+ if _cl_rows:
3389
+ print(f" {'Agent':<10} {'Model':<22} {'Domain':<8} {'Phase':<10} {'Score':>6} N")
3390
+ for _ag, _mv, _dom, _ph, _sc, _n in _cl_rows:
3391
+ _sc_str = f"{_sc:.2f}" if _sc is not None else " — "
3392
+ print(f" {_GREEN}{_ag:<10}{_RESET} {_mv:<22} {_dom:<8} {_ph:<10} {_sc_str:>6} {_n}")
3393
+ else:
3394
+ print(" No capability data yet.")
3395
+ print(sep)
3396
+ sys.exit(1 if has_alert else 0)
3397
+
3398
+ def init(force: bool = False, agents: list = None,
3399
+ org: str = None, repo: str = None, project_id: str = None,
3400
+ mode: str = "solo", dry_run: bool = False, quiet: bool = False,
3401
+ replace_generated_docs: bool = False,
3402
+ non_interactive: bool = False) -> None:
3403
+ """Progressive wizard: semantic scan → harness discovery → doc bootstrap → nudge."""
3404
+
3405
+ # An explicit non-interactive flag always uses defaults. Otherwise keep
3406
+ # prompting even when stdin is not a TTY: callers and tests may provide a
3407
+ # working input function. Closed/exhausted stdin is handled below via
3408
+ # EOFError, which preserves the headless subprocess fallback.
3409
+ auto_defaults = bool(non_interactive)
3410
+
3411
+ def _print_step(n: int, label: str) -> None:
3412
+ print(f"\n{_BOLD}{_CYAN}Step {n}/{_TOTAL_STEPS} — {label}{_RESET}")
3413
+
3414
+ _TOTAL_STEPS = 6
3415
+
3416
+ # ── Step 1: Detect existing state ──────────────────────────────────────
3417
+ _print_step(1, "Scanning repository")
3418
+ synlynk_exists = os.path.exists(".synlynk")
3419
+ if synlynk_exists and not force:
3420
+ print(f" {_YELLOW}⚠ .synlynk/ already exists.{_RESET} "
3421
+ "Use --force to reinitialise.\n Updating agent files only.")
3422
+
3423
+ if dry_run:
3424
+ print(" DRY RUN — no files will be written\n")
3425
+ dd_preview = _docs_dir()
3426
+ for d in [dd_preview, os.path.join(dd_preview, "devlogs"), ".synlynk", LOGS_DIR, PROMPTS_DIR]:
3427
+ if not os.path.exists(d):
3428
+ print(f" would create: {d}/")
3429
+ print(" would always overwrite (marker_style='none', regardless of --force):")
3430
+ for fpath in (".cursor/rules/synlynk.mdc", ".github/copilot-instructions.md",
3431
+ ".windsurfrules", "AI_INSTRUCTIONS.md"):
3432
+ if os.path.exists(fpath):
3433
+ print(f" ⚠ {fpath} (already exists — would be overwritten unconditionally)")
3434
+ else:
3435
+ print(f" {fpath} (would be created)")
3436
+ return
3437
+
3438
+ scan = _static_scan(".")
3439
+ print(f" Project : {_BOLD}{scan['project_name']}{_RESET}")
3440
+ print(f" Commits : {scan['commit_count']}")
3441
+ print(f" Languages: {', '.join(scan['languages']) or 'unknown'}")
3442
+ if scan["recent_topics"]:
3443
+ print(f" Recent : {scan['recent_topics'][0]}")
3444
+ if not scan["has_structured_commits"] and scan["commit_count"] > 0:
3445
+ print(f" {_DIM}⚠ Commit messages don't follow a structured convention — "
3446
+ "skeleton quality may be lower. Review generated docs before proceeding.{_RESET}")
3447
+
3448
+ # ── Step 2: Agent discovery ─────────────────────────────────────────────
3449
+ _print_step(2, "Discovering agents")
3450
+ discovered = discover_agents()
3451
+ functional = [a for a in discovered if a["functional"]]
3452
+ non_functional = [a for a in discovered if not a["functional"]]
3453
+
3454
+ if functional:
3455
+ print(f"\n {_BOLD}{_GREEN}✨ Synlynk is ready to route coding tasks:{_RESET}")
3456
+ for ag in functional:
3457
+ roles = ", ".join(ag["roles"])
3458
+ print(f" {_GREEN}✓ {ag['name']:10}{_RESET} {ag['version']} "
3459
+ f"roles: {roles}")
3460
+ else:
3461
+ print(f" {_YELLOW}No agents detected. Install Claude, Gemini, or Codex to route coding tasks.{_RESET}")
3462
+
3463
+ if non_functional:
3464
+ print(f"\n {_DIM}Found but not configured (run --version failed):{_RESET}")
3465
+ for ag in non_functional:
3466
+ print(f" {_DIM}✗ {ag['name']} — check API key / install{_RESET}")
3467
+
3468
+ from synlynk.rollback import rollback_checkpoint
3469
+
3470
+ config_path = os.path.join(".synlynk", "config.json")
3471
+ rates_path = os.path.join(".synlynk", "model_rates.json")
3472
+ with rollback_checkpoint("init", untracked_paths=[
3473
+ os.path.join(".git", "hooks", "pre-commit"),
3474
+ config_path,
3475
+ os.path.join(".synlynk", "instructions.json"),
3476
+ rates_path,
3477
+ "project-docs",
3478
+ "roadmap.md",
3479
+ "memory.md",
3480
+ "todo.md",
3481
+ "CLAUDE.md",
3482
+ "GEMINI.md",
3483
+ "AGENTS.md",
3484
+ "GROK.md",
3485
+ ".cursor/rules/synlynk.mdc",
3486
+ ".github/copilot-instructions.md",
3487
+ ".windsurfrules",
3488
+ "AI_INSTRUCTIONS.md",
3489
+ ]):
3490
+ # ── Step 3: Create directories + write skeleton ─────────────────────
3491
+ dd = _docs_dir()
3492
+ _print_step(3, f"Bootstrapping {dd}/")
3493
+ for d in [dd, os.path.join(dd, "devlogs"), ".synlynk",
3494
+ LOGS_DIR, PROMPTS_DIR]:
3495
+ if not os.path.exists(d):
3496
+ os.makedirs(d)
3497
+
3498
+ written = _write_informed_skeleton(
3499
+ scan, skip_existing=not force, replace_generated_docs=replace_generated_docs
3500
+ )
3501
+ if written:
3502
+ for p, label in written:
3503
+ print(f" {_GREEN}✓{_RESET} {p} {_DIM}({label}){_RESET}")
3504
+ else:
3505
+ print(f" {_DIM}All docs already exist — skipped (use --force to overwrite){_RESET}")
3506
+
3507
+ # Write agent instruction files using _write_instruction_file().
3508
+ agent_set = set(agents) if agents is not None else {a["name"] for a in functional} or {"claude", "agy", "codex", "grok"}
3509
+ templates = _build_templates(org=org, repo=repo, project_id=project_id)
3510
+
3511
+ # Core trio: only write if agent was discovered as functional.
3512
+ trio_content = {
3513
+ "CLAUDE.md": (templates.get("CLAUDE.md", ""), "html"),
3514
+ "GEMINI.md": (templates.get("GEMINI.md", ""), "html"),
3515
+ "AGENTS.md": (templates.get("AGENTS.md", ""), "html"),
3516
+ "GROK.md": (templates.get("GROK.md", ""), "html"),
3517
+ }
3518
+ _agent_guards = {"CLAUDE.md": "claude", "GEMINI.md": "agy", "AGENTS.md": "codex", "GROK.md": "grok"}
3519
+ for fname, (content, mstyle) in trio_content.items():
3520
+ required = _agent_guards[fname]
3521
+ if required not in agent_set:
3522
+ continue
3523
+ _write_instruction_file(fname, required, content, mstyle)
3524
+
3525
+ # Extended targets: written based on environment detection.
3526
+ # Guards are sourced from _INSTRUCTION_TARGETS[i][3] (detection_fn).
3527
+ _target_detection = {fpath: fn for fpath, _, _, fn in _INSTRUCTION_TARGETS}
3528
+ extended = [
3529
+ (".cursor/rules/synlynk.mdc", "cursor", "none", _build_cursor_mdc()),
3530
+ (".github/copilot-instructions.md", "copilot", "html", _build_copilot_instructions()),
3531
+ (".windsurfrules", "windsurf", "hash", _build_windsurf_rules()),
3532
+ ("AI_INSTRUCTIONS.md", "universal", "html", templates.get("AI_INSTRUCTIONS.md", "")),
3533
+ ]
3534
+ for fpath, tool, mstyle, content in extended:
3535
+ if _target_detection[fpath]():
3536
+ # marker_style='none' means synlynk owns the whole file — always overwrites
3537
+ _write_instruction_file(fpath, tool, content, mstyle)
3538
+
3539
+ # Write manifest of all tracked files with their SHAs.
3540
+ manifest_entries = {}
3541
+ for fpath, tool, mstyle, _ in _INSTRUCTION_TARGETS:
3542
+ if not os.path.exists(fpath):
3543
+ continue
3544
+ file_content = open(fpath).read()
3545
+ section = _extract_synlynk_section(file_content, mstyle)
3546
+ if section is not None:
3547
+ manifest_entries[fpath] = {"tool": tool, "sha": _compute_section_sha(section)}
3548
+ if manifest_entries:
3549
+ _write_instruction_manifest(manifest_entries)
3550
+
3551
+ install_pre_commit_hook(repo_root=Path.cwd())
3552
+
3553
+ # Write config.json if needed.
3554
+ config_json_content = templates.get("config.json", "")
3555
+ if config_json_content:
3556
+ if not os.path.exists(config_path) or force:
3557
+ try:
3558
+ config_payload = json.loads(config_json_content)
3559
+ except (TypeError, json.JSONDecodeError):
3560
+ config_payload = None
3561
+ if isinstance(config_payload, dict):
3562
+ config_payload.setdefault("local_auto_threshold", 0.5)
3563
+ config_payload.setdefault("local_fallback", "agy")
3564
+ config_json_content = json.dumps(config_payload, indent=2) + "\n"
3565
+ with open(config_path, "w") as f:
3566
+ f.write(config_json_content)
3567
+
3568
+ if not os.path.exists(rates_path):
3569
+ from synlynk.costs import _HARDCODED_FALLBACK_RATES
3570
+ rates_seed = dict(_HARDCODED_FALLBACK_RATES)
3571
+ rates_seed["rates_updated_at"] = time.strftime("%Y-%m-%d")
3572
+ with open(rates_path, "w") as f:
3573
+ json.dump(rates_seed, f, indent=2)
3574
+ print(f" ✓ Created {rates_path}")
3575
+
3576
+ # ── Step 4: LLM enrichment offer ────────────────────────────────────────
3577
+ if not quiet:
3578
+ _print_step(4, "LLM enrichment (optional)")
3579
+ if functional:
3580
+ enricher = functional[0]
3581
+ print(f" I found {scan['commit_count']} commits and {len(scan['recent_topics'])} "
3582
+ f"recent topics.\n Want me to ask {enricher['name']} to synthesise a roadmap "
3583
+ f"from this? (costs tokens)")
3584
+ answer = ""
3585
+ if not auto_defaults:
3586
+ try:
3587
+ answer = input(" [y/N] ").strip().lower()
3588
+ except EOFError:
3589
+ auto_defaults = True
3590
+ if answer == "y":
3591
+ print(f" {_DIM}Calling {enricher['cli']} --print...{_RESET}", end=" ", flush=True)
3592
+ ok = _llm_enrich(enricher["name"], enricher["cli"], scan)
3593
+ print(f"{_GREEN}done{_RESET}" if ok else f"{_YELLOW}failed — keeping skeleton{_RESET}")
3594
+ else:
3595
+ print(f" {_DIM}No functional agent available — skipping enrichment{_RESET}")
3596
+
3597
+ # ── Step 5: Cloud directory nudge ────────────────────────────────────────
3598
+ inferred = _infer_industry()
3599
+ if quiet:
3600
+ email = ""
3601
+ industry = inferred
3602
+ else:
3603
+ _print_step(5, "Team & cloud setup (optional)")
3604
+ print(" Add a collaborator or share this workspace with your team.")
3605
+ print(" Leave blank to skip.")
3606
+ if auto_defaults:
3607
+ email = ""
3608
+ industry = inferred
3609
+ else:
3610
+ try:
3611
+ email = input(" Email or synlynk ID: ").strip()
3612
+ industry = input(f" Industry vertical [{inferred}]: ").strip() or inferred
3613
+ except EOFError:
3614
+ auto_defaults = True
3615
+ email = ""
3616
+ industry = inferred
3617
+ if industry not in list(_INDUSTRY_KEYWORDS.keys()) + ["unknown"]:
3618
+ industry = "unknown"
3619
+
3620
+ # ── Step 6: Finalise config ──────────────────────────────────────────────
3621
+ _print_step(6, "Finalising")
3622
+ synlynk_config_path = os.path.join("project-docs", ".synlynk_config.json")
3623
+ if not os.path.exists(synlynk_config_path) or force:
3624
+ config_data = {"mode": mode, "version": VERSION,
3625
+ "init_timestamp": time.strftime("%Y-%m-%dT%H:%M:%S")}
3626
+ with open(synlynk_config_path, "w") as f:
3627
+ json.dump(config_data, f, indent=2)
3628
+
3629
+ _update_config({
3630
+ "workgroup_agents": [a["name"] for a in functional],
3631
+ "workgroup_invite_email": email or None,
3632
+ "industry": industry,
3633
+ })
3634
+
3635
+ set_state("stopped")
3636
+ from synlynk.capability_sweep import _seed_capability_ledger_from_baseline
3637
+
3638
+ _seed_capability_ledger_from_baseline(_get_db())
3639
+
3640
+ # LIVE-21: make probe metadata available before the first dispatch. Do
3641
+ # not probe fallback names when no harness is installed: some harness
3642
+ # probes perform network checks and init must retain its fast headless
3643
+ # path. Explicit ``agents=`` remains authoritative.
3644
+ if agents is not None:
3645
+ probe_targets = sorted(set(agents))
3646
+ else:
3647
+ # Keep the legacy no-argument init path bounded. Explicitly selected
3648
+ # harnesses are probed here; otherwise first dispatch performs the
3649
+ # defensive inline probe for the discovered harness.
3650
+ probe_targets = []
3651
+ probe_all_configured_harnesses(probe_targets)
3652
+
3653
+ if auto_defaults:
3654
+ print(f" Auto-selected defaults: enrichment=no, email=empty, industry={industry}")
3655
+
3656
+ print(f"\n{_BOLD}{_GREEN}✓ synlynk initialised — Synlynk is a neutral control plane that routes coding tasks across AI vendors and local models, then proves the result.{_RESET}")
3657
+ if functional:
3658
+ agent_names = " + ".join(a["name"] for a in functional)
3659
+ print(f"\n {_BOLD}✨ Magic Moment 2 — dispatch agents now:{_RESET}")
3660
+ print(f" {_CYAN}synlynk dispatch {functional[0]['name']} --task \"your task\"{_RESET}")
3661
+ if len(functional) >= 3:
3662
+ print(f" {_CYAN}synlynk run --trio --task \"your task\"{_RESET} "
3663
+ f"← runs {agent_names} in parallel")
3664
+ print(f"\n Background Supervision:")
3665
+ print(f" {_CYAN}synlynk daemon --install-service{_RESET} ← supervise daemon across reboots (launchd/systemd)")
3666
+ print(f"\n Next: {_DIM}synlynk status · synlynk jobs · synlynk dispatch --help{_RESET}\n")
3667
+
3668
+ # --- module extractions (backwards compat) ---
3669
+ # Normal library imports retain the historical package-level exports. CLI
3670
+ # startup skips this fan-out until after argparse has parsed the command.
3671
+ if not _FAST_CLI and not _vizor_daemon_process():
3672
+ _load_legacy_imports()